refactor: migrate to Tailwind CSS v4 and TypeScript
Replaces PostCSS-based styling with Tailwind CSS v4 via Vite plugin Converts all .js files to .ts with proper type annotations Adds comprehensive type definitions for Telegram data structures Extracts reusable UI components and standardizes class utilities Improves maintainability by eliminating custom CSS in favor of Tailwind utilities and design tokens Enhances type safety across Telegram parsing, env access, and API routes Centralizes agent guidelines in AGENTS.md following repository standards Upgrades iconography to astro-icon with Remix Icon integration Expands accessible HTML patterns including ARIA labels, semantic navigation, and keyboard interaction support Refactors static proxy logic into shared utility functions Consolidates modal and image preview markup for consistency
5
.gitignore
vendored
@@ -26,4 +26,7 @@ pnpm-debug.log*
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
.edgeone
|
||||
.edgeone
|
||||
|
||||
.agents
|
||||
.claude
|
||||
160
AGENTS.md
@@ -1,25 +1,159 @@
|
||||
# Repository Guidelines
|
||||
# Repository Guidelines for Coding Agents
|
||||
|
||||
## Project Structure & Modules
|
||||
## 1. Scope
|
||||
|
||||
The codebase is built on Astro 4. Routes live in `src/pages`, shared UI in `src/components`, layouts in `src/layouts`, and Telegram/RSS logic in `src/lib`. Styles and static assets sit under `src/styles` and `src/assets`. Everything placed in `public/` is served as-is by Astro. Helper utilities and automation scripts belong to `api/` and `scripts/`. Build artifacts are emitted to `dist/`; always clean up previous outputs before deploying to avoid stale files.
|
||||
- This repository is an Astro 5 SSR site that turns Telegram channels into a microblog.
|
||||
- Use this file as the main repo-specific guide for coding agents working in this project.
|
||||
- Prefer small, surgical changes that match the existing code style and structure.
|
||||
- Assume server-rendered HTML is the default; client-side JavaScript is intentionally minimal.
|
||||
|
||||
## Build, Test & Dev Commands
|
||||
## 2. Instruction Sources
|
||||
|
||||
Always use `pnpm`. Run `pnpm install` to install dependencies and register simple-git-hooks when `.git` exists. `pnpm dev`/`pnpm start` launch hot reload on port 4321, `pnpm build` creates the deployable SSR bundle, `pnpm preview` validates the build in the production-like adapter, and `pnpm lint` / `pnpm lint:fix` check or auto-fix ESLint issues.
|
||||
- Root `AGENTS.md` is the maintained agent guide for this repository.
|
||||
- No `.cursor/rules/**`, `.cursorrules`, or `.github/copilot-instructions.md` files were found.
|
||||
- If Cursor or Copilot rules are added later, merge them into this file.
|
||||
- `CLAUDE.md` is an older short copy; prefer this file when they disagree.
|
||||
|
||||
## Code Style & Naming
|
||||
## 3. Project Snapshot
|
||||
|
||||
ESLint relies on `@antfu/eslint-config`, `eslint-plugin-astro`, and `eslint-plugin-format`, using two-space indents, single quotes, and automatic trailing commas per syntax. Name Astro components as `PascalCase.astro`, scripts and helpers as `kebab-case.ts`, environment variables with uppercase snake case, and keep route paths kebab-cased. Make sure `pnpm lint` is clean before committing, and add brief clarifying comments when Telegram API or RSS handling might confuse readers.
|
||||
- Package manager: `pnpm` only.
|
||||
- Preferred Node version: `v22`.
|
||||
- Framework: `astro@5`.
|
||||
- Lint stack: `eslint@9` + `@antfu/eslint-config` + `eslint-plugin-astro` + `eslint-plugin-format`.
|
||||
- Styling: Tailwind CSS v4 via `@tailwindcss/vite`.
|
||||
|
||||
## Testing Guidance
|
||||
## 4. Repository Map
|
||||
|
||||
There is no unit-test framework yet. Minimum validation is passing ESLint plus a manual `pnpm preview`. When adding modules, prefer injectable pure functions inside `src/lib` to ease future Vitest adoption. List covered user paths in the PR description (e.g., "channel without TAG" or "RSS Beautify disabled") and mention the commands you ran.
|
||||
- `src/pages/`: Astro pages and API-style route handlers.
|
||||
- `src/components/`: reusable UI pieces and page fragments.
|
||||
- `src/layouts/`: page shells like `base.astro`.
|
||||
- `src/lib/`: Telegram fetching, parsing, env access, proxying, and shared logic.
|
||||
- `src/types.ts`: shared domain interfaces.
|
||||
- `dist/`, `.astro/`, and `node_modules/`: generated output; do not hand-edit.
|
||||
|
||||
## PR & Commit Guidelines
|
||||
## 5. Core Commands
|
||||
|
||||
Follow Conventional Commits (`feat:`, `fix:`, `refactor:`, etc.) and explain the reason and impact in the body. Each PR should include: 1) background or linked issue, 2) notes on config/env changes, 3) local validation output or screenshots, and 4) UI screenshots from `pnpm preview` when relevant. Keep PR scope focused; split into multiple commits if it simplifies rollbacks.
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm start
|
||||
pnpm build
|
||||
pnpm preview
|
||||
pnpm lint:fix
|
||||
```
|
||||
|
||||
## Security & Config Notes
|
||||
- `pnpm eslint <path>` is the supported focused lint command.
|
||||
- Use `pnpm astro ...` only when a task specifically needs extra Astro CLI behavior.
|
||||
|
||||
Deployments depend on `.env` settings such as `CHANNEL`, `LOCALE`, Sentry credentials, and social account URLs. Never commit real tokens—use `.env.example` or platform variables (Vercel/Cloudflare/etc.). To try different Telegram proxies, copy `.env` to `.env.local`; Astro will load it during `astro dev`. Cloudflare/Netlify/Vercel Node adapters live in `astro.config.mjs`, so confirm the target platform supports SSR before changing them.
|
||||
## 6. Testing Reality
|
||||
|
||||
- There is currently **no automated test runner** in this repo.
|
||||
- There is no `pnpm test` script.
|
||||
- No Vitest, Jest, Playwright, or Cypress config files are present.
|
||||
- No `*.test.*` or `*.spec.*` files are present.
|
||||
|
||||
### Single-test guidance
|
||||
|
||||
- There is currently **no supported "run a single test" command**.
|
||||
- If asked to run a single test, say so explicitly instead of inventing a command.
|
||||
- Use `pnpm eslint <path>` for a narrow static check.
|
||||
- Use `pnpm build && pnpm preview` plus manual route verification for behavior checks.
|
||||
|
||||
## 7. Recommended Validation
|
||||
|
||||
- Small refactor or one-file logic change: `pnpm eslint <file>` then `pnpm lint`.
|
||||
- UI or route change: `pnpm lint`, then `pnpm build`, then `pnpm preview`.
|
||||
- Feed, metadata, or sitemap change: verify `/rss.xml`, `/rss.json`, and `/sitemap.xml` manually in preview.
|
||||
- Telegram parsing or proxy change: validate home, one post page, RSS output, and the relevant `/static/...` path.
|
||||
- Env/config change: update docs and validate behavior with representative env values.
|
||||
|
||||
## 8. Formatting Rules
|
||||
|
||||
- Follow ESLint as the formatting source of truth; do not fight the auto-fixer.
|
||||
- Indentation: 2 spaces.
|
||||
- Line endings: LF.
|
||||
- Charset: UTF-8.
|
||||
- Quotes: single quotes.
|
||||
- Semicolons are typically omitted.
|
||||
- Trailing commas should follow linter/formatter output.
|
||||
- Do not do unrelated whitespace churn.
|
||||
|
||||
## 9. Imports
|
||||
|
||||
- Use `import type` for type-only imports.
|
||||
- Keep side-effect imports explicit, e.g. CSS, locale packs, and Prism language loaders.
|
||||
- No path aliases are configured in `tsconfig.json`; use relative imports.
|
||||
- Let ESLint decide final import ordering; run `pnpm lint:fix` after adding or moving imports.
|
||||
- Avoid unused imports.
|
||||
|
||||
## 10. Naming Conventions
|
||||
|
||||
- Keep route filenames aligned with Astro routing: `index.astro`, `[id].astro`, `[...url].ts`, `rss.xml.ts`, etc.
|
||||
- Reusable new Astro components should prefer `PascalCase.astro`.
|
||||
- Preserve neighboring conventions when editing older lowercase Astro files like `header.astro`, `item.astro`, or `base.astro`.
|
||||
- New helper modules should use descriptive kebab-case names when that matches existing utilities.
|
||||
- Environment variables use uppercase snake case.
|
||||
- Shared interfaces and types use PascalCase names.
|
||||
|
||||
## 11. Types
|
||||
|
||||
- Prefer explicit interfaces for shared domain shapes in `src/types.ts`.
|
||||
- Keep shared type definitions centralized instead of re-declaring them across files.
|
||||
- Prefer narrow unions when the allowed values are known, e.g. `'text' | 'service'`.
|
||||
- Avoid `any`; use `unknown`, proper interfaces, or generics.
|
||||
- Type exported handlers and helpers when practical, e.g. `APIRoute`, explicit return types, or typed props.
|
||||
- In Astro files, keep prop shapes obvious near the top of frontmatter.
|
||||
|
||||
## 12. Astro and UI Patterns
|
||||
|
||||
- Keep page frontmatter focused on loading data and preparing view state.
|
||||
- Push reusable logic into `src/lib/` instead of repeating it inside pages.
|
||||
- Use `Astro.locals` for request-scoped values set by middleware.
|
||||
- API-style routes in `src/pages/*.ts` should return `Response` / `Response.json`, not Express-like objects.
|
||||
- Prefer semantic HTML and accessible labels.
|
||||
- Keep browser-side JS near zero; the current deliberate exception is the Telegram comments widget.
|
||||
- Do not add `client:*` directives or inline scripts unless the feature genuinely requires them.
|
||||
- Tailwind utility classes are used heavily in `.astro` files.
|
||||
- For long class strings, follow the existing pattern of extracting them into constants above the markup.
|
||||
- Reuse existing visual tokens and accessible structures instead of inventing near-duplicates.
|
||||
|
||||
## 13. Error Handling
|
||||
|
||||
- Fail fast for required server-side configuration, e.g. throw when mandatory env values are missing.
|
||||
- In request handlers, catch unknown errors only when you can convert them into an explicit HTTP response.
|
||||
- When catching, narrow with `instanceof Error` before reading `.message`.
|
||||
- Do not silently swallow fetch or parsing failures.
|
||||
- Keep error messages actionable and specific.
|
||||
|
||||
## 14. External Fetching, Parsing, and HTML Safety
|
||||
|
||||
- Telegram fetching and parsing live in `src/lib/telegram/index.ts`; keep new scraping logic there.
|
||||
- Preserve the static proxy whitelist behavior in `src/lib/static-proxy.ts` unless the task explicitly changes the security model.
|
||||
- When proxying or forwarding requests, be careful with headers and target validation.
|
||||
- `set:html` is already used in a few places; only feed it sanitized or internally generated HTML.
|
||||
- If introducing new HTML transformations for feeds or pages, sanitize external content first.
|
||||
- Avoid mutating cached data objects in-place when extending cached flows.
|
||||
|
||||
## 15. Env and Config Notes
|
||||
|
||||
- Never commit real secrets or tokens.
|
||||
- Update `.env.example` when introducing, removing, or renaming env variables.
|
||||
- Update README docs when env behavior changes.
|
||||
- Prefer actual code usage over stale README text if they conflict.
|
||||
- Important current code-level env names include `CHANNEL`, `LOCALE`, `TIMEZONE`, `TELEGRAM_HOST`, `STATIC_PROXY`, `COMMENTS`, `REACTIONS`, `NOINDEX`, `NOFOLLOW`, `RSS_BEAUTIFY`, `TAGS`, `LINKS`, `NAVS`, `HEADER_INJECT`, and `FOOTER_INJECT`.
|
||||
- Note that `PODCASRT` is the current code-level env key in `header.astro`; keep compatibility in mind unless intentionally correcting it everywhere.
|
||||
|
||||
## 16. Build and Deployment Notes
|
||||
|
||||
- `astro.config.mjs` selects adapters for Vercel, Cloudflare Pages, Netlify, Node, and EdgeOne.
|
||||
- The app is configured with `output: 'server'`.
|
||||
- Do not change adapter logic casually; deployment behavior depends on environment detection.
|
||||
- If you touch build configuration, run `pnpm build` before finishing.
|
||||
|
||||
## 17. Working Style
|
||||
|
||||
- Inspect nearby files before editing to match local conventions.
|
||||
- Prefer updating existing patterns over introducing new abstractions.
|
||||
- Keep comments sparse and explain why, not what.
|
||||
- If a task would benefit from automated tests, note that the repo currently lacks a test harness instead of pretending one exists.
|
||||
- If you add a real test runner in the future, update `package.json`, this file, and the single-test instructions together.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { GET } from '../../src/pages/static/[...url]'
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
}
|
||||
|
||||
export default function handler(request) {
|
||||
const url = request.url?.split('/static/')?.[1]
|
||||
|
||||
if (!url) {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
const target = new URL(url)
|
||||
target.searchParams.delete('path')
|
||||
|
||||
return GET({
|
||||
request,
|
||||
params: {
|
||||
url: target.origin + target.pathname,
|
||||
},
|
||||
url: {
|
||||
search: target.search,
|
||||
},
|
||||
})
|
||||
}
|
||||
25
api/static/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createStaticProxyResponse } from '../../src/lib/static-proxy'
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
}
|
||||
|
||||
export default async function handler(request: Request): Promise<Response> {
|
||||
try {
|
||||
const urlStr = request.url?.split('/static/')?.[1]
|
||||
|
||||
if (!urlStr) {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = new URL(urlStr)
|
||||
parsed.searchParams.delete('path')
|
||||
|
||||
const rawTarget = parsed.origin + parsed.pathname + parsed.search
|
||||
return await createStaticProxyResponse(request, rawTarget)
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return new Response(message, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import node from '@astrojs/node'
|
||||
import vercel from '@astrojs/vercel'
|
||||
import edgeone from '@edgeone/astro'
|
||||
import sentry from '@sentry/astro'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import astroIcon from 'astro-icon'
|
||||
import { defineConfig } from 'astro/config'
|
||||
import { provider } from 'std-env'
|
||||
|
||||
@@ -33,6 +35,7 @@ export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: providers[adapterProvider] || providers.node,
|
||||
integrations: [
|
||||
astroIcon(),
|
||||
...(process.env.SENTRY_DSN
|
||||
? [
|
||||
sentry({
|
||||
@@ -51,6 +54,7 @@ export default defineConfig({
|
||||
: []),
|
||||
],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
ssr: {
|
||||
noExternal: process.env.DOCKER ? !!process.env.DOCKER : undefined,
|
||||
external: [
|
||||
|
||||
50
package.json
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
@@ -15,38 +15,40 @@
|
||||
"postinstall": "test -d .git && simple-git-hooks || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.14",
|
||||
"@sentry/astro": "^10.32.1",
|
||||
"@astrojs/rss": "^4.0.17",
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
"@sentry/astro": "^10.46.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"flourite": "^1.3.0",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lru-cache": "^11.2.7",
|
||||
"ofetch": "^1.5.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"prismjs-components-importer": "^0.2.0",
|
||||
"sanitize-html": "^2.17.0"
|
||||
"sanitize-html": "^2.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.7.3",
|
||||
"@astrojs/cloudflare": "^12.6.12",
|
||||
"@astrojs/netlify": "^6.6.3",
|
||||
"@astrojs/node": "^9.5.1",
|
||||
"@astrojs/vercel": "^9.0.2",
|
||||
"@edgeone/astro": "^1.0.6",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"astro": "^5.16.6",
|
||||
"astro-eslint-parser": "^1.2.2",
|
||||
"astro-seo": "^0.8.4",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"cssnano": "^7.1.2",
|
||||
"eslint": "9.5.0",
|
||||
"eslint-plugin-astro": "^1.5.0",
|
||||
"eslint-plugin-format": "^1.1.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss-nesting": "^13.0.2",
|
||||
"@antfu/eslint-config": "^7.7.3",
|
||||
"@astrojs/cloudflare": "^12.6.13",
|
||||
"@astrojs/netlify": "^6.6.5",
|
||||
"@astrojs/node": "^9.5.5",
|
||||
"@astrojs/vercel": "^9.0.5",
|
||||
"@edgeone/astro": "^1.1.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"astro": "^5.18.1",
|
||||
"astro-eslint-parser": "^1.4.0",
|
||||
"astro-seo": "^1.1.0",
|
||||
"baseline-browser-mapping": "^2.10.10",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-astro": "^1.6.0",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"std-env": "^3.10.0",
|
||||
"std-env": "^4.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
|
||||
5662
pnpm-lock.yaml
generated
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('postcss-nesting')({
|
||||
edition: '2021',
|
||||
noIsPseudoSelector: true,
|
||||
}),
|
||||
require('autoprefixer'),
|
||||
require('cssnano'),
|
||||
],
|
||||
}
|
||||
15
skills-lock.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"fixing-accessibility": {
|
||||
"source": "ibelick/ui-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "4fa8f5a772b4eef3d28a54c136b507d070e54e2c326578f65eaf5e5a0118b38a"
|
||||
},
|
||||
"fixing-metadata": {
|
||||
"source": "ibelick/ui-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "699e13890fb50acffa02dfd2d5b0e719bd916a9e2b7e6035bdc4513fc73d9174"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
|
||||
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
|
||||
<path d="M407.8 294.7c-3.3-.4-6.7-.8-10-1.3c3.4 .4 6.7 .9 10 1.3zM288 227.1C261.9 176.4 190.9 81.9 124.9 35.3C61.6-9.4 37.5-1.7 21.6 5.5C3.3 13.8 0 41.9 0 58.4S9.1 194 15 213.9c19.5 65.7 89.1 87.9 153.2 80.7c3.3-.5 6.6-.9 10-1.4c-3.3 .5-6.6 1-10 1.4C74.3 308.6-9.1 342.8 100.3 464.5C220.6 589.1 265.1 437.8 288 361.1c22.9 76.7 49.2 222.5 185.6 103.4c102.4-103.4 28.1-156-65.8-169.9c-3.3-.4-6.7-.8-10-1.3c3.4 .4 6.7 .9 10 1.3c64.1 7.1 133.6-15.1 153.2-80.7C566.9 194 576 75 576 58.4s-3.3-44.7-21.6-52.9c-15.8-7.1-40-14.9-103.2 29.8C385.1 81.9 314.1 176.4 288 227.1z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 793 B |
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>discord</title>
|
||||
<path d="M20.992 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.050 0.005 0.109 0.005 0.168 0 1.523-1.191 2.768-2.693 2.854l-0.008 0zM11.026 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.048 0.005 0.104 0.005 0.161 0 1.525-1.19 2.771-2.692 2.862l-0.008 0zM26.393 6.465c-1.763-0.832-3.811-1.49-5.955-1.871l-0.149-0.022c-0.005-0.001-0.011-0.002-0.017-0.002-0.035 0-0.065 0.019-0.081 0.047l-0 0c-0.234 0.411-0.488 0.924-0.717 1.45l-0.043 0.111c-1.030-0.165-2.218-0.259-3.428-0.259s-2.398 0.094-3.557 0.275l0.129-0.017c-0.27-0.63-0.528-1.142-0.813-1.638l0.041 0.077c-0.017-0.029-0.048-0.047-0.083-0.047-0.005 0-0.011 0-0.016 0.001l0.001-0c-2.293 0.403-4.342 1.060-6.256 1.957l0.151-0.064c-0.017 0.007-0.031 0.019-0.040 0.034l-0 0c-2.854 4.041-4.562 9.069-4.562 14.496 0 0.907 0.048 1.802 0.141 2.684l-0.009-0.11c0.003 0.029 0.018 0.053 0.039 0.070l0 0c2.14 1.601 4.628 2.891 7.313 3.738l0.176 0.048c0.008 0.003 0.018 0.004 0.028 0.004 0.032 0 0.060-0.015 0.077-0.038l0-0c0.535-0.72 1.044-1.536 1.485-2.392l0.047-0.1c0.006-0.012 0.010-0.027 0.010-0.043 0-0.041-0.026-0.075-0.062-0.089l-0.001-0c-0.912-0.352-1.683-0.727-2.417-1.157l0.077 0.042c-0.029-0.017-0.048-0.048-0.048-0.083 0-0.031 0.015-0.059 0.038-0.076l0-0c0.157-0.118 0.315-0.24 0.465-0.364 0.016-0.013 0.037-0.021 0.059-0.021 0.014 0 0.027 0.003 0.038 0.008l-0.001-0c2.208 1.061 4.8 1.681 7.536 1.681s5.329-0.62 7.643-1.727l-0.107 0.046c0.012-0.006 0.025-0.009 0.040-0.009 0.022 0 0.043 0.008 0.059 0.021l-0-0c0.15 0.124 0.307 0.248 0.466 0.365 0.023 0.018 0.038 0.046 0.038 0.077 0 0.035-0.019 0.065-0.046 0.082l-0 0c-0.661 0.395-1.432 0.769-2.235 1.078l-0.105 0.036c-0.036 0.014-0.062 0.049-0.062 0.089 0 0.016 0.004 0.031 0.011 0.044l-0-0.001c0.501 0.96 1.009 1.775 1.571 2.548l-0.040-0.057c0.017 0.024 0.046 0.040 0.077 0.040 0.010 0 0.020-0.002 0.029-0.004l-0.001 0c2.865-0.892 5.358-2.182 7.566-3.832l-0.065 0.047c0.022-0.016 0.036-0.041 0.039-0.069l0-0c0.087-0.784 0.136-1.694 0.136-2.615 0-5.415-1.712-10.43-4.623-14.534l0.052 0.078c-0.008-0.016-0.022-0.029-0.038-0.036l-0-0z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="64px" height="64px" viewBox="0 0 64 64" id="i-github" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-width="0" fill="currentColor" d="M32 0 C14 0 0 14 0 32 0 53 19 62 22 62 24 62 24 61 24 60 L24 55 C17 57 14 53 13 50 13 50 13 49 11 47 10 46 6 44 10 44 13 44 15 48 15 48 18 52 22 51 24 50 24 48 26 46 26 46 18 45 12 42 12 31 12 27 13 24 15 22 15 22 13 18 15 13 15 13 20 13 24 17 27 15 37 15 40 17 44 13 49 13 49 13 51 20 49 22 49 22 51 24 52 27 52 31 52 42 45 45 38 46 39 47 40 49 40 52 L40 60 C40 61 40 62 42 62 45 62 64 53 64 32 64 14 50 0 32 0 Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 576 B |
@@ -1,157 +0,0 @@
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
view-transition-name: site-title;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.item {
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
#aside-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#aside-container .nav {
|
||||
position: static;
|
||||
top: unset;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 0px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: '🔍';
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: var(--background-color);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon:checked + .search-form {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: none;
|
||||
background: var(--code-background-color);
|
||||
color: var(--secondary-color);
|
||||
padding: 8px;
|
||||
border-radius: var(--box-border-radius);
|
||||
|
||||
> input {
|
||||
border: 1px solid var(--background-color);
|
||||
border-radius: var(--box-border-radius);
|
||||
background: var(--code-background-color);
|
||||
color: var(--secondary-color);
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
line-height: 2.4;
|
||||
padding: 0 0.5em;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.copyright-wrap {
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
#aside-container {
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.copyright-wrap {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
#back-to-top {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--code-background-color);
|
||||
color: var(--secondary-color);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 100%;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
z-index: 1000;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
img {
|
||||
filter: var(--icon-secondary-filter);
|
||||
}
|
||||
}
|
||||
|
||||
/* Use @scroll-timeline to control the display of the button */
|
||||
@supports (animation-timeline: view()) {
|
||||
#back-to-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.5s linear both;
|
||||
animation-timeline: view(block 0 100vh);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
to {
|
||||
opacity: 0.9;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
.content {
|
||||
word-break: break-word;
|
||||
|
||||
.image-list-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: masonry;
|
||||
|
||||
&.image-list-odd {
|
||||
:first-child {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: calc(100% - var(--box-margin));
|
||||
}
|
||||
|
||||
> pre {
|
||||
width: calc(100% - var(--box-margin));
|
||||
max-width: 456px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tgme_widget_message_link_preview {
|
||||
margin-top: 16px;
|
||||
display: none;
|
||||
|
||||
.link_preview_site_name,
|
||||
.link_preview_title,
|
||||
.link_preview_description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_link_preview:has(.link_preview_site_name) {
|
||||
display: block;
|
||||
background: var(--cell-background-color);
|
||||
border-left: 3px solid var(--highlight-color);
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
border-radius: var(--box-border-radius);
|
||||
|
||||
.link_preview_title {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bolder;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.link_preview_description {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_video,
|
||||
.tgme_widget_message_roundvideo {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.tgme_widget_message_link_preview:has(.link_preview_image) {
|
||||
display: flex;
|
||||
position: relative;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
.link_preview_image {
|
||||
aspect-ratio: 1200 / 630;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.link_preview_site_name {
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: var(--box-margin);
|
||||
left: var(--box-margin);
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.66);
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--box-border-radius);
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - 28px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link_preview_title,
|
||||
.link_preview_description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 16px 0;
|
||||
font-size: 0.8em;
|
||||
background: var(--cell-background-color);
|
||||
border-left: 3px solid var(--highlight-color);
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
border-radius: var(--box-border-radius);
|
||||
}
|
||||
|
||||
.tg-expandable {
|
||||
margin: 16px 0;
|
||||
font-size: 0.8em;
|
||||
background: var(--cell-background-color);
|
||||
border-left: 3px solid var(--highlight-color);
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
padding-right: 30px;
|
||||
border-radius: var(--box-border-radius);
|
||||
position: relative;
|
||||
min-height: 3.6em;
|
||||
}
|
||||
|
||||
.tg-expandable__checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tg-expandable__content {
|
||||
display: block;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* use @supports to check :has() support */
|
||||
@supports selector(:has(*)) {
|
||||
/* when :has() is supported, default to collapsed */
|
||||
.tg-expandable__content {
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* when checkbox is checked, expand */
|
||||
.tg-expandable:has(.tg-expandable__checkbox:checked) .tg-expandable__content {
|
||||
display: block;
|
||||
line-clamp: unset;
|
||||
-webkit-line-clamp: unset;
|
||||
-webkit-box-orient: unset;
|
||||
overflow: visible;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.tg-expandable__toggle {
|
||||
/* default hidden, not show small triangle when :has() is not supported */
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports selector(:has(*)) {
|
||||
.tg-expandable__toggle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tg-expandable__toggle::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid var(--secondary-color);
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform 0.2s ease;
|
||||
filter: var(--icon-secondary-filter);
|
||||
}
|
||||
|
||||
.tg-expandable:has(.tg-expandable__checkbox:checked) .tg-expandable__toggle::after {
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_sticker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:has(.tgme_widget_message_user_photo) {
|
||||
display: flex;
|
||||
|
||||
.tgme_widget_message_user_photo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_voice {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.tgme_widget_message_video_wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tgme_widget_message_poll_options {
|
||||
display: block;
|
||||
|
||||
.tgme_widget_message_poll_option_percent {
|
||||
float: left;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_location_wrap {
|
||||
display: block;
|
||||
.tgme_widget_message_location {
|
||||
padding-top: 50%;
|
||||
background: no-repeat center;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-style: normal;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.tg-emoji {
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.sticker {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.spoiler-button {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tg-spoiler {
|
||||
color: transparent;
|
||||
margin: auto 2px;
|
||||
border-radius: var(--box-border-radius);
|
||||
background: #ccc 60% 60% / 3000px 3000px;
|
||||
background-image: repeating-conic-gradient(#999 0 0.0001%, #0000 0 0.0002%);
|
||||
}
|
||||
|
||||
input:checked + tg-spoiler {
|
||||
background: unset;
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reaction-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px 3px 6px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-color);
|
||||
background: var(--code-background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.reaction-pill-paid {
|
||||
background: rgba(255, 196, 0, 0.12);
|
||||
border-color: rgba(255, 196, 0, 0.35);
|
||||
color: #9a6a00;
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.reaction-emoji img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reaction-count {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tag-box {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
[popover] {
|
||||
display: none;
|
||||
&:popover-open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-wrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-preview-button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.modal-img {
|
||||
margin: auto;
|
||||
max-width: calc(100% - 40px) !important;
|
||||
max-height: calc(100% - 40px) !important;
|
||||
border-radius: var(--media-border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadows);
|
||||
cursor: pointer;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
.modal-img {
|
||||
max-width: calc(100% - 80px) !important;
|
||||
max-height: calc(100% - 80px) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||
<!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
|
||||
<path d="M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 819 B |
351
src/assets/normalize.css
vendored
@@ -1,351 +0,0 @@
|
||||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct the line height in all browsers.
|
||||
* 2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `main` element consistently in IE.
|
||||
*/
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the gray background on active links in IE 10.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Chrome 57-
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none; /* 1 */
|
||||
text-decoration: underline; /* 2 */
|
||||
text-decoration: underline dotted; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers.
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input {
|
||||
/* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
/* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type='button']::-moz-focus-inner,
|
||||
[type='reset']::-moz-focus-inner,
|
||||
[type='submit']::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
button:-moz-focusring,
|
||||
[type='button']:-moz-focusring,
|
||||
[type='reset']:-moz-focusring,
|
||||
[type='submit']:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the padding in Firefox.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE 10+.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10.
|
||||
* 2. Remove the padding in IE 10.
|
||||
*/
|
||||
|
||||
[type='checkbox'],
|
||||
[type='radio'] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type='number']::-webkit-inner-spin-button,
|
||||
[type='number']::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type='search']::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in Edge, IE 10+, and Firefox.
|
||||
*/
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Misc
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10+.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="11" r="1" />
|
||||
<path d="M17.03 18.46a9 9 0 10-10.02.03" />
|
||||
<path d="M16.06 13.91a5 5 0 10-7.97.2" />
|
||||
<path d="M11.11 17a.9.9 0 111.78 0l-.52 4.67a.37.37 0 01-.74 0l-.52-4.68z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 429 B |
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 310 310" style="enable-background:new 0 0 310 310;" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M90.244,264.828C90.244,240.11,70.139,220,45.427,220c-24.715,0-44.822,20.11-44.822,44.828
|
||||
c0,24.714,20.107,44.82,44.822,44.82C70.139,309.648,90.244,289.542,90.244,264.828z"/>
|
||||
<path d="M5.648,169.43c35.961,0,69.782,14.066,95.231,39.605c25.45,25.583,39.467,59.648,39.467,95.92
|
||||
c0,2.762,2.238,5,5,5h57.486c2.762,0,5-2.238,5-5c0-111.952-90.699-203.031-202.185-203.031c-2.762,0-5,2.238-5,5v57.505
|
||||
C0.648,167.191,2.887,169.43,5.648,169.43z"/>
|
||||
<path id="XMLID_791_" d="M5.726,0c-2.762,0-5,2.238-5,5v57.495c0,2.762,2.238,5,5,5c130.24,0,236.199,106.544,236.199,237.505
|
||||
c0,2.762,2.238,5,5,5h57.471c2.762,0,5-2.238,5-5C309.396,136.822,173.17,0,5.726,0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 952 B |
@@ -1,900 +0,0 @@
|
||||
:root {
|
||||
--background-color: #f4f1ec;
|
||||
--foreground-color: #000000;
|
||||
--highlight-color: orangered;
|
||||
--box-border-radius: 3px;
|
||||
--media-border-radius: 8px;
|
||||
--dot-size: 8px;
|
||||
--shadows:
|
||||
0 1px 2px rgba(0, 0, 0, 0.02), 0 2px 4px rgba(0, 0, 0, 0.02), 0 4px 8px rgba(0, 0, 0, 0.02),
|
||||
0 8px 16px rgba(0, 0, 0, 0.02);
|
||||
--box-margin: 10px;
|
||||
--border-color: rgba(0, 0, 0, 0.05);
|
||||
--link-color: var(--highlight-color);
|
||||
--icon-hover-filter: invert(51%) sepia(0%) saturate(0%) hue-rotate(23deg) brightness(90%) contrast(90%);
|
||||
--icon-secondary-filter: invert(97%) sepia(0%) saturate(0%) hue-rotate(129deg) brightness(86%) contrast(88%);
|
||||
--cell-background-color: #fff;
|
||||
--code-background-color: #f9f9f9;
|
||||
--secondary-color: #999;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background-color);
|
||||
color: var(--foreground-color);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
Noto Sans,
|
||||
sans-serif,
|
||||
Apple Color Emoji,
|
||||
Segoe UI Emoji,
|
||||
Segoe UI Symbol,
|
||||
Noto Color Emoji;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
#modal {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
display: none;
|
||||
}
|
||||
|
||||
#modal-img {
|
||||
margin: auto;
|
||||
max-width: calc(100% - 40px);
|
||||
max-height: calc(100% - 40px);
|
||||
border-radius: var(--media-border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadows);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #778087;
|
||||
text-decoration: none;
|
||||
line-break: loose;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #4d5256;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
a.site-title:link,
|
||||
a.site-title:visited {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.site-title:hover {
|
||||
color: #000;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a.item-link:link,
|
||||
a.item-link:visited {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.item-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0px auto 0px auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
flex: 1;
|
||||
padding-top: 20px;
|
||||
padding-right: 30px;
|
||||
padding-bottom: 20px;
|
||||
margin-right: 20px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#header {
|
||||
padding: 10px 10px 10px 10px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 100%;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: var(--shadows);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
margin-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.header-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: bottom;
|
||||
filter: var(--icon-secondary-filter);
|
||||
}
|
||||
|
||||
.social-icon:hover {
|
||||
filter: var(--icon-hover-filter);
|
||||
}
|
||||
|
||||
#breadcrumb {
|
||||
padding: 10px 0px 10px 0px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumb-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: var(--shadows);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.breadcrumb-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 2px;
|
||||
color: var(--border-color);
|
||||
background-color: var(--border-color);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
padding: 10px 10px 10px 10px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
#site-intro {
|
||||
padding: 10px 20px 10px 20px;
|
||||
background-color: #fff;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
border-radius: var(--box-border-radius);
|
||||
box-shadow: var(--shadows);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 0px 20px 0px 20px;
|
||||
}
|
||||
|
||||
.items {
|
||||
margin-top: 20px;
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
.item {
|
||||
}
|
||||
|
||||
.image-box,
|
||||
.video-box,
|
||||
.audio-box,
|
||||
.attachment-box {
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding: 30px 0px 30px 30px;
|
||||
display: flex;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.attachment-box {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.attachment-box img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--media-border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadows);
|
||||
}
|
||||
|
||||
.image-box > img {
|
||||
display: block;
|
||||
width: calc(100% - 1px);
|
||||
height: auto;
|
||||
max-height: initial;
|
||||
border-radius: var(--media-border-radius);
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--shadows);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.video-box video {
|
||||
max-width: 100%;
|
||||
border-radius: var(--media-border-radius);
|
||||
box-shadow: var(--shadows);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-play-button {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-panel {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.audio-box audio {
|
||||
width: 100%;
|
||||
border-radius: var(--media-border-radius);
|
||||
box-shadow: var(--shadows);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.title-box {
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding: 30px 0px 0px 30px;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.title-box:last-child {
|
||||
padding-top: 30px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.tag-box {
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding: 0px 0px 30px 30px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-left: 3px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-box:last-child {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.text-box {
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding: 30px 0px 30px 30px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.reaction-box {
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding: 6px 0px 24px 30px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.text-box + .reaction-box {
|
||||
margin-top: -12px;
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.text-box p:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.text-box p:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.time-box {
|
||||
padding: 0px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-box > .dot {
|
||||
width: var(--dot-size);
|
||||
height: var(--dot-size);
|
||||
border-radius: var(--dot-size);
|
||||
background-color: var(--link-color);
|
||||
}
|
||||
|
||||
.time-box > .time {
|
||||
flex: 1;
|
||||
color: var(--link-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
#aside-container {
|
||||
padding-bottom: 20px;
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#aside-container .nav {
|
||||
padding-top: 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--box-margin);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
}
|
||||
|
||||
.nav-link:link,
|
||||
.nav-link:visited {
|
||||
flex: 1;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
padding: 5px 10px 5px 10px;
|
||||
border-radius: var(--box-border-radius);
|
||||
display: inline-block;
|
||||
transition:
|
||||
background-color 0.15s ease-in-out,
|
||||
box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-link.current {
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
box-shadow: var(--shadows);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.65);
|
||||
box-shadow: var(--shadows);
|
||||
}
|
||||
|
||||
/* START: archive */
|
||||
|
||||
.archive-container {
|
||||
margin: 0px auto 40px auto;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.archive-title {
|
||||
font-size: 2em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.archive-count {
|
||||
font-size: 0.8em;
|
||||
color: var(--foreground-secondary-color);
|
||||
}
|
||||
|
||||
.archive-list {
|
||||
column-count: 2;
|
||||
column-rule: 1px solid var(--border-color);
|
||||
column-gap: 40px;
|
||||
line-height: 1.2;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.archive-list-item {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.archive-list-header {
|
||||
display: block;
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
color: var(--foreground-secondary-color);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.archive-list-header:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.archive-list-item a {
|
||||
line-break: initial;
|
||||
}
|
||||
|
||||
/* END: archive */
|
||||
|
||||
/* START: tag */
|
||||
|
||||
.tag-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-size: 16px 16px;
|
||||
opacity: 0.25;
|
||||
background-image: url('tags.png');
|
||||
background-repeat: no-repeat;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tag:link,
|
||||
.tag:visited {
|
||||
color: var(--secondary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px 2px 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--link-color);
|
||||
border-color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag-container {
|
||||
margin: 0px auto 40px auto;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.tag-caption {
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
font-size: 2em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
font-size: 0.8em;
|
||||
color: var(--foreground-secondary-color);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
color: var(--foreground-secondary-color);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tags {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
column-count: 2;
|
||||
column-rule: 1px solid var(--border-color);
|
||||
column-gap: 40px;
|
||||
line-height: 2;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tag-cloud-item {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.tag-cloud-item-count {
|
||||
display: inline-block;
|
||||
background-color: var(--border-color);
|
||||
font-size: 0.75em;
|
||||
color: var(--background-color);
|
||||
padding: 1px 4px 1px 4px;
|
||||
border-radius: 20px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.5em 0.2em 0.5em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tag-item:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--link-color);
|
||||
box-shadow: 0px 1px 2px var(--border-color);
|
||||
}
|
||||
|
||||
/* END: tag */
|
||||
|
||||
/* START: Markdown tags */
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.content h3 {
|
||||
font-size: 16px;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.content h4 {
|
||||
font-size: 14px;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.content h5 {
|
||||
font-size: 12px;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.content h6 {
|
||||
font-size: 10px;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.content ul:first-child,
|
||||
.content ol:first-child,
|
||||
.content p:first-child,
|
||||
.content h1:first-child,
|
||||
.content h2:first-child,
|
||||
.content h3:first-child,
|
||||
.content h4:first-child,
|
||||
.content h5:first-child,
|
||||
.content h6:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.content ul:last-child,
|
||||
.content ol:last-child,
|
||||
.content p:last-child,
|
||||
.content h1:last-child,
|
||||
.content h2:last-child,
|
||||
.content h3:last-child,
|
||||
.content h4:last-child,
|
||||
.content h5:last-child,
|
||||
.content h6:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.content li {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.content img {
|
||||
max-width: calc(100% - 1px);
|
||||
max-height: initial;
|
||||
border-radius: var(--media-border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadows);
|
||||
}
|
||||
|
||||
.content a:link,
|
||||
.content a:visited {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.content pre {
|
||||
font-size: 0.9em;
|
||||
white-space: pre-wrap;
|
||||
padding: 10px;
|
||||
border-radius: var(--media-border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadows);
|
||||
background-color: rgba(255, 255, 240, 0.2);
|
||||
}
|
||||
|
||||
.content code {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.content figure {
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.content figcaption {
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.content > iframe {
|
||||
max-width: calc(100% - 1px);
|
||||
border-radius: var(--media-border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadows);
|
||||
}
|
||||
|
||||
/* END: Markdown tags */
|
||||
|
||||
/* START: pages */
|
||||
|
||||
.pages-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pages-info {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
a.page:link,
|
||||
a.page:visited {
|
||||
color: var(--secondary-color);
|
||||
padding: 5px 15px 5px 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 30px;
|
||||
border: 1px solid var(--secondary-color);
|
||||
}
|
||||
|
||||
a.page:hover {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-placeholder {
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
/* END: pages */
|
||||
|
||||
/* START: table */
|
||||
|
||||
.content table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border-collapse: collapse;
|
||||
box-shadow: var(--shadows);
|
||||
}
|
||||
|
||||
.content table tr {
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--cell-background-color);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content table tr:nth-child(2n) {
|
||||
background-color: var(--code-background-color);
|
||||
}
|
||||
|
||||
.content table tr th {
|
||||
font-weight: bold;
|
||||
border: 1px solid var(--border-color);
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--code-background-color);
|
||||
}
|
||||
|
||||
.content table tr td {
|
||||
border: 1px solid var(--border-color);
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
.content table tr th :first-child,
|
||||
.content table tr td :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content table tr th :last-child,
|
||||
.content table tr td :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* END: table */
|
||||
|
||||
/* START: To Do Items */
|
||||
|
||||
ul:has(input[type='checkbox']) {
|
||||
padding-inline-start: 0px;
|
||||
}
|
||||
|
||||
li input[type='checkbox'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
li:has(input[type='checkbox']) {
|
||||
list-style-type: none;
|
||||
margin-left: 0px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
li:has(input[type='checkbox']:not(:checked):disabled)::before {
|
||||
background-image: url('./circle.svg');
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
width: 1.25em;
|
||||
height: 1em;
|
||||
background-size: 1em 1em;
|
||||
margin-top: 2px;
|
||||
filter: var(--icon-hover-filter);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
li:has(input[type='checkbox']:checked:disabled)::before {
|
||||
background-image: url('./checkmark.circle.fill.svg');
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
width: 1.25em;
|
||||
height: 1em;
|
||||
background-size: 1em 1em;
|
||||
margin-top: 1px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* END: To Do Items */
|
||||
|
||||
@media screen and (max-width: 799px) {
|
||||
#container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
padding-right: 0px;
|
||||
margin-right: 0px;
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
#header {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.items {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
#aside-container {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
background-color: var(--background-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
box-shadow: 0px 4px 16px -16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#aside-container .nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#aside-container .nav-item {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 10px 0px 0px 0px;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.tag-cloud-item {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.archive-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.archive-list-item {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.archive-list-header {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
#site-intro {
|
||||
margin-left: 0px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m20.665 3.717-17.73 6.837c-1.21.486-1.203 1.161-.222 1.462l4.552 1.42 10.532-6.645c.498-.303.953-.14.579.192l-8.533 7.701h-.002l.002.001-.314 4.692c.46 0 .663-.211.921-.46l2.211-2.15 4.599 3.397c.848.467 1.457.227 1.668-.785l3.019-14.228c.309-1.239-.473-1.8-1.282-1.434z"/></svg>
|
||||
|
Before Width: | Height: | Size: 375 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="64px" height="64px" viewBox="0 0 64 64" id="i-twitter" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-width="0" fill="currentColor" d="M60 16 L54 17 L58 12 L51 14 C42 4 28 15 32 24 C16 24 8 12 8 12 C8 12 2 21 12 28 L6 26 C6 32 10 36 17 38 L10 38 C14 46 21 46 21 46 C21 46 15 51 4 51 C37 67 57 37 54 21 Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 332 B |
42
src/components/TagCloudSection.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
export interface Item {
|
||||
href: string
|
||||
label: string
|
||||
title?: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
const { title, items } = Astro.props as {
|
||||
title: string
|
||||
items: Item[]
|
||||
}
|
||||
|
||||
const sectionTitleClass = 'm-0 px-[10px] text-[16px] font-semibold leading-none text-heading'
|
||||
const tagCloudClass =
|
||||
'mt-4 mb-0 list-none pl-0 leading-normal [column-count:1] [column-gap:20px] sm:[column-count:2] sm:[column-gap:40px] sm:[column-rule:1px_solid_var(--color-line)]'
|
||||
const tagCloudItemClass = 'mb-2 block break-inside-avoid'
|
||||
const tagLinkClass =
|
||||
'inline-block rounded-[4px] border border-line px-[10px] py-0.5 text-muted no-underline hover:border-accent hover:text-accent hover:no-underline'
|
||||
---
|
||||
|
||||
<section aria-labelledby={`${title.toLowerCase()}-title`}>
|
||||
<h1 id={`${title.toLowerCase()}-title`} class={sectionTitleClass}>{title}</h1>
|
||||
|
||||
<ul class={tagCloudClass} role="list">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class={tagCloudItemClass}>
|
||||
<a
|
||||
href={item.href}
|
||||
class={tagLinkClass}
|
||||
title={item.title ?? item.label}
|
||||
target={item.external ? '_blank' : undefined}
|
||||
rel={item.external ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -1,14 +1,8 @@
|
||||
---
|
||||
import { getEnv } from '../lib/env'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import voidFile from '../assets/void.png'
|
||||
import rss from '../assets/rss.svg'
|
||||
import podcast from '../assets/podcast.svg'
|
||||
import twitter from '../assets/twitter.svg'
|
||||
import github from '../assets/github.svg'
|
||||
import discord from '../assets/discord.svg'
|
||||
import telegram from '../assets/telegram.svg'
|
||||
import mastodon from '../assets/mastodon.svg'
|
||||
import bluesky from '../assets/bluesky.svg'
|
||||
import { siteTitleStyle } from '../lib/ui'
|
||||
|
||||
const { SITE_URL, RSS_URL } = Astro.locals
|
||||
const { channel } = Astro.props
|
||||
@@ -23,108 +17,125 @@ const BLUESKY = getEnv(import.meta.env, Astro, 'BLUESKY')
|
||||
|
||||
const staticProxy = getEnv(import.meta.env, Astro, 'STATIC_PROXY') ?? '/static/'
|
||||
const hideDescription = getEnv(import.meta.env, Astro, 'HIDE_DESCRIPTION')
|
||||
|
||||
const headerMainClass = 'mb-[10px] flex items-center p-[10px] font-semibold max-sm:p-0'
|
||||
const avatarClass = 'block h-10 w-10 rounded-full border-[3px] border-white object-cover shadow-soft'
|
||||
const titleWrapClass = 'ml-[10px] min-w-0 flex-1 pr-[10px] text-[20px]'
|
||||
const titleLinkClass = 'text-heading no-underline hover:text-ink hover:underline'
|
||||
const iconNavClass = 'flex items-center gap-[2px]'
|
||||
const socialLinkClass = 'group p-1 no-underline'
|
||||
const socialIconClass =
|
||||
'h-[1em] w-[1em] align-bottom [filter:var(--icon-secondary-filter)] group-hover:[filter:var(--icon-hover-filter)]'
|
||||
const introClass =
|
||||
'mb-5 ml-[3px] break-words rounded-panel border-l-2 border-line bg-code px-5 py-[10px] text-base leading-[1.6] text-muted shadow-soft max-sm:my-5 max-sm:ml-0'
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
href: RSS_URL,
|
||||
title: 'RSS Feed',
|
||||
label: 'RSS Feed',
|
||||
icon: 'ri:rss-line',
|
||||
rel: 'alternate noopener noreferrer',
|
||||
type: 'application/rss+xml',
|
||||
},
|
||||
PODCASRT && {
|
||||
href: PODCASRT,
|
||||
title: 'Podcast',
|
||||
label: 'Podcast',
|
||||
icon: 'ri:mic-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
TWITTER &&
|
||||
TWITTER.length > 0 && {
|
||||
href: `https://x.com/${TWITTER}`,
|
||||
title: 'Twitter',
|
||||
label: 'Twitter / X',
|
||||
icon: 'ri:twitter-x-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
GITHUB &&
|
||||
GITHUB.length > 0 && {
|
||||
href: `https://github.com/${GITHUB}`,
|
||||
title: 'GitHub',
|
||||
label: 'GitHub',
|
||||
icon: 'ri:github-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
TELEGRAM &&
|
||||
TELEGRAM.length > 0 && {
|
||||
href: `https://t.me/${TELEGRAM}`,
|
||||
title: 'Telegram',
|
||||
label: 'Telegram',
|
||||
icon: 'ri:telegram-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
DISCORD &&
|
||||
DISCORD.length > 0 && {
|
||||
href: DISCORD,
|
||||
title: 'Discord',
|
||||
label: 'Discord',
|
||||
icon: 'ri:discord-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
MASTODON &&
|
||||
MASTODON.length > 0 && {
|
||||
href: `https://${MASTODON}`,
|
||||
title: 'Mastodon',
|
||||
label: 'Mastodon',
|
||||
icon: 'ri:mastodon-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
BLUESKY &&
|
||||
BLUESKY.length > 0 && {
|
||||
href: `https://bsky.app/profile/${BLUESKY}`,
|
||||
title: 'BlueSky',
|
||||
label: 'Bluesky',
|
||||
icon: 'ri:bluesky-line',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
].filter(Boolean)
|
||||
---
|
||||
|
||||
<div id="header">
|
||||
<a href={SITE_URL} title={channel?.title}>
|
||||
<img
|
||||
src={channel?.avatar?.startsWith('http') ? staticProxy + channel?.avatar : voidFile.src}
|
||||
alt={channel?.title}
|
||||
loading="eager"
|
||||
class="header-avatar"
|
||||
/>
|
||||
</a>
|
||||
<div class="header-title">
|
||||
<a href={SITE_URL} class="site-title" title={channel?.title}>
|
||||
{channel?.title}
|
||||
<header>
|
||||
<div class={headerMainClass}>
|
||||
<a href={SITE_URL} title={channel?.title}>
|
||||
<img
|
||||
src={channel?.avatar?.startsWith('http') ? staticProxy + channel?.avatar : voidFile.src}
|
||||
alt={channel?.title}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
width="40"
|
||||
height="40"
|
||||
class={avatarClass}
|
||||
/>
|
||||
</a>
|
||||
<div class={titleWrapClass}>
|
||||
<a href={SITE_URL} class={titleLinkClass} style={siteTitleStyle} title={channel?.title}>
|
||||
{channel?.title}
|
||||
</a>
|
||||
</div>
|
||||
<nav class={iconNavClass} aria-label="Channel links">
|
||||
{
|
||||
socialLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
title={item.title}
|
||||
target="_blank"
|
||||
rel={item.rel}
|
||||
type={item.type}
|
||||
aria-label={item.label}
|
||||
class={socialLinkClass}
|
||||
>
|
||||
<Icon name={item.icon} class={socialIconClass} aria-hidden="true" />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-icons">
|
||||
<a href={RSS_URL} target="_blank" rel="alternate" type="application/rss+xml" title="RSS Feed">
|
||||
<img {...rss} alt="RSS" class="social-icon" width="1em" />
|
||||
</a>
|
||||
|
||||
{
|
||||
PODCASRT && (
|
||||
<a href={PODCASRT} target="_blank" title="Podcast">
|
||||
<img {...podcast} alt="Podcast" class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
TWITTER && TWITTER.length > 0 && (
|
||||
<a href={`https://twitter.com/${TWITTER}`} title="Twitter" target="_blank">
|
||||
<img {...twitter} alt={`twitter.com/${TWITTER}`} class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
GITHUB && GITHUB.length > 0 && (
|
||||
<a href={`https://github.com/${GITHUB}`} title="GitHub" target="_blank">
|
||||
<img {...github} alt={`github.com/${GITHUB}`} class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
TELEGRAM && TELEGRAM.length > 0 && (
|
||||
<a href={`https://t.me/${TELEGRAM}`} title="Telegram" target="_blank">
|
||||
<img {...telegram} alt={`t.me/${TELEGRAM}`} class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
DISCORD && DISCORD.length > 0 && (
|
||||
<a href={DISCORD} title="Discord" target="_blank">
|
||||
<img {...discord} alt="Discord Invite" class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
MASTODON && MASTODON.length > 0 && (
|
||||
<a href={`https://${MASTODON}`} title="Mastodon" target="_blank">
|
||||
<img {...mastodon} alt={`@${MASTODON}`} class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
BLUESKY && BLUESKY.length > 0 && (
|
||||
<a href={`https://bsky.app/profile/${BLUESKY}`} title="BlueSky" target="_blank">
|
||||
<img {...bluesky} alt={`@${BLUESKY}`} class="social-icon" width="1em" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{
|
||||
hideDescription !== 'true' && channel?.descriptionHTML && channel?.descriptionHTML.length > 0 && (
|
||||
<div class="text-box" id="site-intro" set:html={channel?.descriptionHTML} />
|
||||
<div class:list={[introClass, 'content']} set:html={channel?.descriptionHTML} />
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
#site-intro {
|
||||
color: var(--secondary-color);
|
||||
background-color: var(--code-background-color);
|
||||
word-break: break-word;
|
||||
|
||||
& :global(.emoji) {
|
||||
font-style: normal;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.header-icons {
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
import '../assets/item.css'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import dayjs from '../lib/dayjs'
|
||||
import { getEnv } from '../lib/env'
|
||||
|
||||
@@ -18,34 +16,64 @@ const REACTIONS = getEnv(import.meta.env, Astro, 'REACTIONS')
|
||||
|
||||
const datetime = dayjs(post.datetime).tz(timezone)
|
||||
const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('HH:mm · ll · ddd') : datetime.fromNow()
|
||||
const hasContent = post.content.length > 0
|
||||
const hasReactions = Boolean(REACTIONS && post.reactions?.length > 0)
|
||||
const hasTags = post.tags.length > 0
|
||||
const articleClass = 'transition duration-200'
|
||||
const timeBoxClass = 'flex items-center leading-none'
|
||||
const timeDotClass = 'h-[8px] w-[8px] rounded-full bg-accent'
|
||||
const timeTextClass = 'm-0 flex-1 pl-[10px] text-[14px] font-medium text-accent'
|
||||
const itemLinkClass = 'text-accent no-underline hover:underline'
|
||||
const contentClass = 'ml-[3px] border-l-2 border-line py-[30px] pl-[15px] text-base leading-[1.6] sm:pl-[30px]'
|
||||
const reactionBoxClass = 'ml-[3px] border-l-2 border-line pb-6 pl-[15px] pt-[6px] sm:pl-[30px]'
|
||||
const reactionListClass = 'm-0 flex flex-wrap gap-[6px]'
|
||||
const reactionPillClass =
|
||||
'inline-flex items-center gap-1 rounded-full border border-line bg-code py-[3px] pl-[6px] pr-[8px] text-[12px] text-muted'
|
||||
const reactionPaidClass = 'border-[rgba(255,196,0,0.35)] bg-[rgba(255,196,0,0.12)] text-[#9a6a00]'
|
||||
const reactionEmojiClass = 'inline-flex items-center text-[14px] leading-none'
|
||||
const reactionCountClass = 'font-medium opacity-80 [font-variant-numeric:tabular-nums]'
|
||||
const tagBoxClass =
|
||||
'ml-[3px] flex flex-wrap items-center gap-2 border-l-2 border-line pl-[15px] text-[14px] leading-[1.6] sm:pl-[30px]'
|
||||
const tagPaddingClass = COMMENTS && isItem ? 'pb-[30px]' : 'pb-5'
|
||||
const tagStandaloneClass = 'pt-[30px]'
|
||||
const tagLinkClass =
|
||||
'inline-block rounded-[4px] border border-line px-[10px] py-[2px] text-muted no-underline hover:border-accent hover:text-accent hover:no-underline'
|
||||
const commentsClass = 'ml-[3px] border-l-2 border-line pb-6 pl-[15px] pt-[6px] sm:pl-[30px]'
|
||||
---
|
||||
|
||||
<div class="item" style={{ 'view-transition-name': `post-${post.id}` }}>
|
||||
<div class="time-box">
|
||||
<div class="dot"></div>
|
||||
<div class="time">
|
||||
<a href={`${SITE_URL}posts/${post.id}`} title={post.datetime} class="item-link">
|
||||
<article class={articleClass} style={{ 'view-transition-name': `post-${post.id}` }}>
|
||||
<header class={timeBoxClass}>
|
||||
<span class={timeDotClass} aria-hidden="true"></span>
|
||||
<p class={timeTextClass}>
|
||||
<a href={`${SITE_URL}posts/${post.id}`} title={post.datetime} class={itemLinkClass}>
|
||||
<time datetime={post.datetime} title={timeago}>{timeago}</time>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{post.content.length > 0 && <div class={`text-box content`} set:html={post.content} />}
|
||||
</p>
|
||||
</header>
|
||||
{hasContent && <div class:list={[contentClass, 'content']} set:html={post.content} />}
|
||||
{
|
||||
REACTIONS && post.reactions?.length > 0 && (
|
||||
<div class="reaction-box">
|
||||
<div class="reaction-list">
|
||||
hasReactions && (
|
||||
<div class:list={[reactionBoxClass, hasContent && '-mt-3 pt-0']}>
|
||||
<div class={reactionListClass}>
|
||||
{post.reactions.map((reaction) => (
|
||||
<span class={`reaction-pill${reaction.isPaid ? ' reaction-pill-paid' : ''}`}>
|
||||
<span class="reaction-emoji">
|
||||
{
|
||||
reaction.isPaid
|
||||
? '\u2B50'
|
||||
: reaction.emojiImage
|
||||
? <img src={reaction.emojiImage} alt={reaction.emoji || 'emoji'} loading="lazy" />
|
||||
: reaction.emoji
|
||||
}
|
||||
<span class:list={[reactionPillClass, reaction.isPaid && reactionPaidClass]}>
|
||||
<span class={reactionEmojiClass}>
|
||||
{reaction.isPaid ? (
|
||||
'\u2B50'
|
||||
) : reaction.emojiImage ? (
|
||||
<img
|
||||
src={reaction.emojiImage}
|
||||
alt={reaction.emoji || 'emoji'}
|
||||
loading="lazy"
|
||||
width="20"
|
||||
height="20"
|
||||
class="block h-[1em] w-[1em]"
|
||||
/>
|
||||
) : (
|
||||
reaction.emoji
|
||||
)}
|
||||
</span>
|
||||
<span class="reaction-count">{reaction.count}</span>
|
||||
<span class={reactionCountClass}>{reaction.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -53,21 +81,22 @@ const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('H
|
||||
)
|
||||
}
|
||||
{
|
||||
post.tags.length > 0 && (
|
||||
<div class="tag-box" style={post.content.length === 0 ? 'padding-top: 30px;' : ''}>
|
||||
<div class="tag-icon" />
|
||||
hasTags && (
|
||||
<footer class:list={[tagBoxClass, tagPaddingClass, !hasContent && tagStandaloneClass]}>
|
||||
<span class="tag-icon" aria-hidden="true" />
|
||||
{post.tags.map((tag) => (
|
||||
<a href={`/search/%23${tag}`} title={tag} class="tag">
|
||||
<a href={`/search/%23${tag}`} title={tag} class={tagLinkClass}>
|
||||
{tag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
COMMENTS && isItem && (
|
||||
<div class="comments">
|
||||
<section class={commentsClass} aria-label="Comments">
|
||||
{/* Telegram 评论是当前唯一保留的客户端 JS 例外。 */}
|
||||
<script
|
||||
is:inline
|
||||
async
|
||||
@@ -77,7 +106,7 @@ const timeago = datetime.isBefore(dayjs().subtract(1, 'w')) ? datetime.format('H
|
||||
data-colorful="1"
|
||||
data-color="454545"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -4,42 +4,59 @@ import Header from '../components/header.astro'
|
||||
import Item from '../components/item.astro'
|
||||
|
||||
const { SITE_URL } = Astro.locals
|
||||
const { channel, before = true, after = true, isItem = false } = Astro.props
|
||||
const { channel, before = true, after = true, isItem = false, pageHeading } = Astro.props
|
||||
const posts = channel.posts ?? []
|
||||
|
||||
const beforeCursor = posts[posts.length - 1]?.id
|
||||
const afterCursor = posts[0]?.id
|
||||
const itemsClass = 'mt-5 mb-0 ml-7 list-none pl-0 max-sm:ml-0'
|
||||
const paginationClass = 'my-5 flex items-center'
|
||||
const paginationLinkClass =
|
||||
'inline-flex min-h-9 items-center justify-center rounded-[30px] border border-muted px-[15px] py-[5px] text-[14px] font-medium text-muted no-underline hover:text-accent hover:no-underline active:bg-line'
|
||||
const paginationInfoClass = 'flex-1 text-center align-middle text-[12px] font-medium text-muted'
|
||||
const paginationPlaceholderClass = 'inline-block w-[34px]'
|
||||
// const cursor = +Astro.params.cursor
|
||||
---
|
||||
|
||||
<Layout channel={channel} id="main-container">
|
||||
<Layout channel={channel}>
|
||||
{pageHeading ? <h1 class="sr-only">{pageHeading}</h1> : null}
|
||||
<slot name="header">
|
||||
<Header channel={channel} />
|
||||
</slot>
|
||||
<div class="items">
|
||||
{posts.map((post) => <Item post={post} isItem={isItem} />)}
|
||||
</div>
|
||||
<ol class={itemsClass} aria-label={isItem ? 'Post' : 'Posts'}>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<Item post={post} isItem={isItem} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
|
||||
<div class="pages-container">
|
||||
<nav class={paginationClass} aria-label="Pagination">
|
||||
{
|
||||
before && beforeCursor > 1 ? (
|
||||
<a href={`${SITE_URL}before/${beforeCursor}`} title="Before" class="page">
|
||||
<a href={`${SITE_URL}before/${beforeCursor}`} title="Before" class={paginationLinkClass}>
|
||||
Before
|
||||
</a>
|
||||
) : (
|
||||
<span class="page-placeholder"> </span>
|
||||
<span class={paginationPlaceholderClass} aria-hidden="true">
|
||||
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="pages-info"></div>
|
||||
<div class={paginationInfoClass}></div>
|
||||
{
|
||||
after && afterCursor ? (
|
||||
<a href={`${SITE_URL}after/${afterCursor}`} title="After" class="page">
|
||||
<a href={`${SITE_URL}after/${afterCursor}`} title="After" class={paginationLinkClass}>
|
||||
After
|
||||
</a>
|
||||
) : (
|
||||
<span class="page-placeholder"> </span>
|
||||
<span class={paginationPlaceholderClass} aria-hidden="true">
|
||||
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</Layout>
|
||||
|
||||
55
src/env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
SITE_URL: string
|
||||
@@ -7,3 +8,57 @@ declare namespace App {
|
||||
RSS_PREFIX: string
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'sanitize-html' {
|
||||
export interface SanitizeHtmlFrame {
|
||||
tag: string
|
||||
attribs: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SanitizeHtmlOptions {
|
||||
allowedTags?: string[]
|
||||
allowedAttributes?: Record<string, string[]>
|
||||
exclusiveFilter?: (frame: SanitizeHtmlFrame) => boolean
|
||||
}
|
||||
|
||||
interface SanitizeHtml {
|
||||
(dirty: string, options?: SanitizeHtmlOptions): string
|
||||
defaults: {
|
||||
allowedTags: string[]
|
||||
allowedAttributes: Record<string, string[]>
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHtml: SanitizeHtml
|
||||
|
||||
export default sanitizeHtml
|
||||
}
|
||||
|
||||
// prismjs-components-importer ships CJS files without type declarations.
|
||||
// Each import is a side-effect that registers the language grammar on the
|
||||
// global Prism instance, so the module value is irrelevant.
|
||||
declare module 'prismjs-components-importer/cjs/prism-c' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-clojure' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-cpp' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-csharp' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-css' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-dart' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-docker' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-elixir' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-go' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-markup' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-java' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-javascript' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-json' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-julia' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-kotlin' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-lua' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-markdown' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-pascal' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-php' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-python' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-ruby' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-rust' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-sql' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-typescript' {}
|
||||
declare module 'prismjs-components-importer/cjs/prism-yaml' {}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
---
|
||||
import '../assets/normalize.css'
|
||||
import '../assets/style.css'
|
||||
import '../assets/global.css'
|
||||
import '../styles/app.css'
|
||||
import { SEO } from 'astro-seo'
|
||||
import { getEnv } from '../lib/env'
|
||||
import backToTopIcon from '../assets/back-to-top.svg'
|
||||
import type { NavItem } from '../types'
|
||||
|
||||
const { SITE_URL, RSS_URL, RSS_PREFIX } = Astro.locals
|
||||
const { channel } = Astro.props
|
||||
@@ -12,27 +11,51 @@ const { channel } = Astro.props
|
||||
const locale = getEnv(import.meta.env, Astro, 'LOCALE')
|
||||
|
||||
const seo = channel?.seo
|
||||
const reqPathname = Astro.url.pathname.replace(/\/$/, '')
|
||||
const canonical = SITE_URL.startsWith('http') ? new URL(SITE_URL).origin + reqPathname : Astro.url.origin + reqPathname
|
||||
|
||||
const { origin, pathname } = new URL(canonical)
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname.replace(/\/$/, '') || '/'
|
||||
}
|
||||
|
||||
const absoluteSiteUrl = SITE_URL.startsWith('http') ? SITE_URL : new URL(SITE_URL, Astro.url.origin).toString()
|
||||
const canonicalUrl = new URL(Astro.url.pathname, absoluteSiteUrl)
|
||||
const siteRootPathname = normalizePathname(new URL(absoluteSiteUrl).pathname)
|
||||
const canonical =
|
||||
normalizePathname(canonicalUrl.pathname) === siteRootPathname
|
||||
? canonicalUrl.toString()
|
||||
: canonicalUrl.toString().replace(/\/$/, '')
|
||||
|
||||
const { pathname } = new URL(canonical)
|
||||
const currentPathname = normalizePathname(pathname)
|
||||
const twitter = getEnv(import.meta.env, Astro, 'TWITTER')
|
||||
const pageTitle = seo?.title?.trim()
|
||||
const siteTitle = channel?.title ?? ''
|
||||
const seoDescription = seo?.text ?? channel?.description
|
||||
const shareImage = channel?.avatar
|
||||
? `https://wsrv.nl/?w=1200&h=630&fit=cover&url=ssl:${channel.avatar.replace(/^https?:\/\//, '')}`
|
||||
: new URL('favicon.ico', absoluteSiteUrl).toString()
|
||||
const favicon = channel?.avatar
|
||||
? `https://wsrv.nl/?w=64&h=64&fit=cover&mask=circle&url=ssl:${channel.avatar.replace(/^https?:\/\//, '')}`
|
||||
: new URL('favicon.svg', absoluteSiteUrl).toString()
|
||||
const hasCustomTitle = Boolean(pageTitle && pageTitle !== siteTitle)
|
||||
const isArticle = /\/posts\/[^/]+$/.test(pathname)
|
||||
const tagsPathname = normalizePathname(new URL('tags', absoluteSiteUrl).pathname)
|
||||
const linksPathname = normalizePathname(new URL('links', absoluteSiteUrl).pathname)
|
||||
|
||||
const seoParams = {
|
||||
title: seo?.title,
|
||||
description: seo?.text ?? channel?.description,
|
||||
title: pageTitle,
|
||||
description: seoDescription,
|
||||
canonical,
|
||||
noindex: seo?.noindex ?? getEnv(import.meta.env, Astro, 'NOINDEX'),
|
||||
nofollow: seo?.nofollow ?? getEnv(import.meta.env, Astro, 'NOFOLLOW'),
|
||||
openGraph: {
|
||||
basic: {
|
||||
type: 'website',
|
||||
title: channel?.title ?? '',
|
||||
type: isArticle ? 'article' : 'website',
|
||||
title: pageTitle ?? siteTitle,
|
||||
url: canonical,
|
||||
image: channel?.avatar ? channel.avatar : origin + '/favicon.ico',
|
||||
image: shareImage,
|
||||
},
|
||||
optional: {
|
||||
description: seo?.text ?? channel?.description,
|
||||
description: seoDescription,
|
||||
locale,
|
||||
},
|
||||
},
|
||||
@@ -40,31 +63,64 @@ const seoParams = {
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
href: channel?.avatar
|
||||
? `https://wsrv.nl/?w=64&h=64&fit=cover&mask=circle&url=ssl:${channel?.avatar?.replace(/^https?:\/\//, '')}`
|
||||
: '/favicon.svg',
|
||||
href: favicon,
|
||||
},
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: new URL('favicon.ico', absoluteSiteUrl).toString(),
|
||||
},
|
||||
{
|
||||
rel: 'manifest',
|
||||
href: new URL('site.webmanifest', absoluteSiteUrl).toString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const GOOGLE_SEARCH_SITE = getEnv(import.meta.env, Astro, 'GOOGLE_SEARCH_SITE')
|
||||
const searchAction = GOOGLE_SEARCH_SITE ? 'https://www.google.com/search' : '/search/result'
|
||||
const searchAction = GOOGLE_SEARCH_SITE ? 'https://www.google.com/search' : `${SITE_URL}search/result`
|
||||
|
||||
const HEADER_INJECT = getEnv(import.meta.env, Astro, 'HEADER_INJECT')
|
||||
const FOOTER_INJECT = getEnv(import.meta.env, Astro, 'FOOTER_INJECT')
|
||||
const TAGS = getEnv(import.meta.env, Astro, 'TAGS')
|
||||
const LINKS = getEnv(import.meta.env, Astro, 'LINKS')
|
||||
const navs = (getEnv(import.meta.env, Astro, 'NAVS') || '')
|
||||
const navs: NavItem[] = (getEnv(import.meta.env, Astro, 'NAVS') || '')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((link) => {
|
||||
link = link.split(',')
|
||||
const [title = '', href = ''] = link.split(',')
|
||||
return {
|
||||
title: link[0],
|
||||
href: link[1],
|
||||
title,
|
||||
href,
|
||||
}
|
||||
})
|
||||
|
||||
const layoutShellClass = 'mx-5'
|
||||
const layoutGridClass = 'mx-auto flex w-full max-w-[800px] flex-col-reverse sm:flex-row sm:items-start'
|
||||
const mainPanelClass = 'w-full min-w-0 pb-5 pt-[10px] sm:mr-5 sm:flex-1 sm:border-r sm:border-line sm:pr-[30px] sm:pt-5'
|
||||
const asidePanelClass =
|
||||
'sticky top-0 w-full min-w-0 border-b border-line bg-paper pb-[10px] shadow-[0_4px_16px_-16px_rgba(0,0,0,0.1)] sm:w-[200px] sm:min-w-[200px] sm:self-start sm:border-b-0 sm:bg-transparent sm:pb-5 sm:shadow-none'
|
||||
const asideInnerClass = 'relative overflow-y-visible sm:max-h-[100svh] sm:overflow-y-auto'
|
||||
const navListClass = 'm-0 flex list-none flex-wrap gap-[2px] pl-0 pt-5 sm:block'
|
||||
const navItemClass = 'flex items-center text-[14px] leading-none sm:mb-[10px] sm:text-base sm:leading-normal'
|
||||
const navLinkClass =
|
||||
'flex-1 inline-block rounded-panel px-[10px] py-[5px] text-heading no-underline transition-[background-color,box-shadow] duration-150 ease-in-out hover:bg-white/65 hover:shadow-soft hover:no-underline'
|
||||
const navLinkCurrentClass = 'bg-white/75 shadow-soft'
|
||||
const skipLinkClass =
|
||||
'pointer-events-none absolute left-5 top-0 z-[1100] -translate-y-full rounded-panel bg-heading px-3 py-2 text-sm text-white no-underline transition-transform duration-150 focus-visible:pointer-events-auto focus-visible:translate-y-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-heading'
|
||||
const searchToggleId = 'search-toggle'
|
||||
const searchToggleClass = 'peer sr-only sm:hidden'
|
||||
const searchToggleLabelClass =
|
||||
'absolute right-0 top-5 inline-flex h-6 w-6 items-center justify-center rounded-panel bg-paper text-base leading-6 text-heading no-underline peer-focus-visible:outline peer-focus-visible:outline-1 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-heading sm:hidden'
|
||||
const searchFormClass = 'mt-3 hidden rounded-panel bg-code p-2 text-muted peer-checked:block sm:mt-0 sm:block'
|
||||
const searchInputClass =
|
||||
'box-border w-full rounded-panel border border-paper bg-code px-2 text-[16px] leading-[2.4] text-muted outline-none placeholder:text-muted focus-visible:border-heading focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-heading sm:text-[12px]'
|
||||
const footerClass = 'hidden p-2 text-[14px] leading-[1.5] text-footer sm:block'
|
||||
const backToTopClass =
|
||||
'pointer-events-auto z-[1000] flex h-8 w-8 items-center justify-center rounded-full bg-code text-[24px] opacity-90 transition-transform duration-300 hover:-translate-y-[3px] active:translate-y-px'
|
||||
const backToTopIconClass = 'h-auto w-auto [filter:var(--icon-secondary-filter)]'
|
||||
const skipLinkText = 'Skip to main content'
|
||||
const searchLabelText = 'Search'
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -74,14 +130,9 @@ const navs = (getEnv(import.meta.env, Astro, 'NAVS') || '')
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f4f1ec" />
|
||||
<link rel="alternate" type="application/rss+xml" title={`${RSS_PREFIX}${channel?.title}`} href={RSS_URL} />
|
||||
<style is:inline>
|
||||
@view-transition {
|
||||
navigation: auto; /* enabled */
|
||||
}
|
||||
</style>
|
||||
<SEO
|
||||
titleTemplate={`%s | ${channel?.title}`}
|
||||
titleDefault={[channel?.title, seoParams.description].filter(Boolean).join(' - ')}
|
||||
titleTemplate={hasCustomTitle ? `%s | ${siteTitle}` : undefined}
|
||||
titleDefault={siteTitle || seoParams.description}
|
||||
twitter={{
|
||||
card: 'summary_large_image',
|
||||
creator: twitter ? `@${twitter}` : undefined,
|
||||
@@ -92,81 +143,122 @@ const navs = (getEnv(import.meta.env, Astro, 'NAVS') || '')
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
<div id="container">
|
||||
<div id="main-container">
|
||||
<a href="#main-content" class={skipLinkClass}>{skipLinkText}</a>
|
||||
<main id="main-content" class={layoutShellClass}>
|
||||
<div class={layoutGridClass}>
|
||||
<div class={mainPanelClass}>
|
||||
<slot />
|
||||
</div>
|
||||
<div id="aside-container">
|
||||
<aside class={asidePanelClass}>
|
||||
<slot name="aside">
|
||||
<div class="nav">
|
||||
<div class="nav-item">
|
||||
<a href={SITE_URL} title={channel?.title} class={`nav-link ${pathname === '/' ? 'current' : ''}`}>
|
||||
Home
|
||||
<div class={asideInnerClass}>
|
||||
<nav aria-label="Primary navigation">
|
||||
<ul class={navListClass}>
|
||||
<li class={navItemClass}>
|
||||
<a
|
||||
href={SITE_URL}
|
||||
title={channel?.title}
|
||||
class:list={[navLinkClass, currentPathname === siteRootPathname && navLinkCurrentClass]}
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
TAGS ? (
|
||||
<li class={navItemClass}>
|
||||
<a
|
||||
href={`${SITE_URL}tags`}
|
||||
title="Tags"
|
||||
class:list={[navLinkClass, currentPathname === tagsPathname && navLinkCurrentClass]}
|
||||
>
|
||||
Tags
|
||||
</a>
|
||||
</li>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
LINKS ? (
|
||||
<li class={navItemClass}>
|
||||
<a
|
||||
href={`${SITE_URL}links`}
|
||||
title="Links"
|
||||
class:list={[navLinkClass, currentPathname === linksPathname && navLinkCurrentClass]}
|
||||
>
|
||||
Links
|
||||
</a>
|
||||
</li>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
navs.map((nav) => (
|
||||
<li class={navItemClass}>
|
||||
<a
|
||||
href={nav.href}
|
||||
title={nav.title}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={navLinkClass}
|
||||
>
|
||||
{nav.title}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<input
|
||||
id={searchToggleId}
|
||||
class={searchToggleClass}
|
||||
name="icon"
|
||||
type="checkbox"
|
||||
aria-label="Toggle search"
|
||||
/>
|
||||
<label for={searchToggleId} class={searchToggleLabelClass} aria-hidden="true">🔍</label>
|
||||
<form class={searchFormClass} action={searchAction} method="get" role="search">
|
||||
{GOOGLE_SEARCH_SITE ? <input type="hidden" name="as_sitesearch" value={GOOGLE_SEARCH_SITE} /> : null}
|
||||
<label class="sr-only" for="search-query">{searchLabelText}</label>
|
||||
<input
|
||||
id="search-query"
|
||||
class={searchInputClass}
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search…"
|
||||
autocomplete="off"
|
||||
inputmode="search"
|
||||
spellcheck={false}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<footer class={footerClass}>
|
||||
Powered by
|
||||
<a
|
||||
href="https://github.com/miantiao-me/BroadcastChannel"
|
||||
title="BroadcastChannel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
BroadcastChannel
|
||||
</a> &
|
||||
<a
|
||||
href="https://github.com/Planetable/SiteTemplateSepia"
|
||||
title="Sepia"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Sepia
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
TAGS ? (
|
||||
<div class="nav-item">
|
||||
<a
|
||||
href={`${SITE_URL}tags`}
|
||||
title="Tags"
|
||||
class={`nav-link ${pathname === '/tags' ? 'current' : ''}`}
|
||||
>
|
||||
Tags
|
||||
</a>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
LINKS ? (
|
||||
<div class="nav-item">
|
||||
<a
|
||||
href={`${SITE_URL}links`}
|
||||
title="Links"
|
||||
class={`nav-link ${pathname === '/links' ? 'current' : ''}`}
|
||||
>
|
||||
Links
|
||||
</a>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
navs.map((nav) => (
|
||||
<div class="nav-item">
|
||||
<a href={nav.href} title={nav.title} target="_blank" rel="noopener" class="nav-link">
|
||||
{nav.title}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<input class="search-icon" name="icon" type="checkbox" placeholder="Search" />
|
||||
<form class="search-form" action={searchAction} method="get">
|
||||
{GOOGLE_SEARCH_SITE ? <input type="hidden" name="as_sitesearch" value={GOOGLE_SEARCH_SITE} /> : null}
|
||||
<input type="text" name="q" placeholder="Search" />
|
||||
</form>
|
||||
<div class="copyright-wrap">
|
||||
Powered by
|
||||
<a
|
||||
href="https://github.com/miantiao-me/BroadcastChannel"
|
||||
title="BroadcastChannel"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
BroadcastChannel
|
||||
</a> &
|
||||
<a href="https://github.com/Planetable/SiteTemplateSepia" title="Sepia" target="_blank" rel="noopener">
|
||||
Sepia
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#wrapper" id="back-to-top" aria-label="Back to top">
|
||||
<img {...backToTopIcon} alt="Back to Top" />
|
||||
</a>
|
||||
<div id="back-to-top-wrapper">
|
||||
<a href="#main-content" id="back-to-top" class={backToTopClass} aria-label="Back to top">
|
||||
<img {...backToTopIcon} class={backToTopIconClass} alt="" />
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<Fragment set:html={FOOTER_INJECT} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getEnv(env, Astro, name) {
|
||||
return env[name] ?? Astro.locals?.runtime?.env?.[name]
|
||||
}
|
||||
13
src/lib/env.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { EnvCapableAstro } from '../types'
|
||||
|
||||
/**
|
||||
* Reads an env variable from Vite's import.meta.env first, then falls back to
|
||||
* the Cloudflare/runtime env bindings exposed via Astro.locals.runtime.env.
|
||||
*/
|
||||
export function getEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
Astro: EnvCapableAstro,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
return env[name] ?? Astro.locals?.runtime?.env?.[name]
|
||||
}
|
||||
29
src/lib/static-proxy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const TARGET_WHITELIST = [
|
||||
't.me',
|
||||
'telegram.org',
|
||||
'telegram.me',
|
||||
'telegram.dog',
|
||||
'cdn-telegram.org',
|
||||
'telesco.pe',
|
||||
'yandex.ru',
|
||||
]
|
||||
|
||||
export function resolveStaticProxyTarget(rawTarget: string): URL {
|
||||
const normalizedTarget = rawTarget.startsWith('//') ? `https:${rawTarget}` : rawTarget
|
||||
return new URL(normalizedTarget)
|
||||
}
|
||||
|
||||
export function isStaticProxyWhitelisted(target: URL): boolean {
|
||||
return TARGET_WHITELIST.some(domain => target.hostname.endsWith(domain))
|
||||
}
|
||||
|
||||
export async function createStaticProxyResponse(request: Request, rawTarget: string): Promise<Response> {
|
||||
const target = resolveStaticProxyTarget(rawTarget)
|
||||
|
||||
if (!isStaticProxyWhitelisted(target)) {
|
||||
return new Response('Proxy target not allowed', { status: 403 })
|
||||
}
|
||||
|
||||
const response = await fetch(target.toString(), request)
|
||||
return new Response(response.body, response)
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import * as cheerio from 'cheerio'
|
||||
import flourite from 'flourite'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { getEnv } from '../env'
|
||||
import prism from '../prism'
|
||||
|
||||
const cache = new LRUCache({
|
||||
ttl: 1000 * 60 * 5, // 5 minutes
|
||||
maxSize: 50 * 1024 * 1024, // 50MB
|
||||
sizeCalculation: (item) => {
|
||||
return JSON.stringify(item).length
|
||||
},
|
||||
})
|
||||
|
||||
// Normalize emoji variants (e.g., heart variants)
|
||||
function normalizeEmoji(emoji) {
|
||||
const emojiMap = {
|
||||
'\u2764': '\u2764\uFE0F',
|
||||
'\u263A': '\u263A\uFE0F',
|
||||
'\u2639': '\u2639\uFE0F',
|
||||
'\u2665': '\u2764\uFE0F',
|
||||
}
|
||||
return emojiMap[emoji] || emoji
|
||||
}
|
||||
|
||||
function getCustomEmojiImage(emojiId, staticProxy = '') {
|
||||
if (!emojiId)
|
||||
return null
|
||||
const imageUrl = `https://t.me/i/emoji/${emojiId}.webp`
|
||||
return `${staticProxy}${imageUrl}`
|
||||
}
|
||||
|
||||
async function hydrateTgEmoji($, content, { staticProxy } = {}) {
|
||||
const emojiNodes = $(content).find('tg-emoji')?.toArray() ?? []
|
||||
if (!emojiNodes.length)
|
||||
return
|
||||
|
||||
await Promise.all(emojiNodes.map((emojiEl) => {
|
||||
const emojiId = $(emojiEl).attr('emoji-id')
|
||||
if (!emojiId)
|
||||
return
|
||||
|
||||
const imageUrl = getCustomEmojiImage(emojiId, staticProxy)
|
||||
if (imageUrl) {
|
||||
const imageMarkup = `<img class="tg-emoji" src="${imageUrl}" alt="" loading="lazy" />`
|
||||
$(emojiEl).replaceWith(imageMarkup)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function getVideoStickers($, item, { staticProxy, index }) {
|
||||
return $(item).find('.js-videosticker_video')?.map((_index, video) => {
|
||||
const url = $(video)?.attr('src')
|
||||
const imgurl = $(video).find('img')?.attr('src')
|
||||
return `
|
||||
<div style="background-image: none; width: 256px;">
|
||||
<video src="${staticProxy + url}" width="100%" height="100%" alt="Video Sticker" preload muted autoplay loop playsinline disablepictureinpicture >
|
||||
<img class="sticker" src="${staticProxy + imgurl}" alt="Video Sticker" loading="${index > 15 ? 'eager' : 'lazy'}" />
|
||||
</video>
|
||||
</div>
|
||||
`
|
||||
})?.get()?.join('')
|
||||
}
|
||||
|
||||
function getImageStickers($, item, { staticProxy, index }) {
|
||||
return $(item).find('.tgme_widget_message_sticker')?.map((_index, image) => {
|
||||
const url = $(image)?.attr('data-webp')
|
||||
return `<img class="sticker" src="${staticProxy + url}" style="width: 256px;" alt="Sticker" loading="${index > 15 ? 'eager' : 'lazy'}" />`
|
||||
})?.get()?.join('')
|
||||
}
|
||||
|
||||
function getImages($, item, { staticProxy, id, index, title }) {
|
||||
const images = $(item).find('.tgme_widget_message_photo_wrap')?.map((_index, photo) => {
|
||||
const url = $(photo).attr('style').match(/url\(["'](.*?)["']/)?.[1]
|
||||
const popoverId = `modal-${id}-${_index}`
|
||||
return `
|
||||
<button class="image-preview-button image-preview-wrap" popovertarget="${popoverId}" popovertargetaction="show">
|
||||
<img src="${staticProxy + url}" alt="${title}" loading="${index > 15 ? 'eager' : 'lazy'}" />
|
||||
</button>
|
||||
<button class="image-preview-button modal" id="${popoverId}" popovertarget="${popoverId}" popovertargetaction="hide" popover>
|
||||
<img class="modal-img" src="${staticProxy + url}" alt="${title}" loading="lazy" />
|
||||
</button>
|
||||
`
|
||||
})?.get()
|
||||
return images.length ? `<div class="image-list-container ${images.length % 2 === 0 ? 'image-list-even' : 'image-list-odd'}">${images?.join('')}</div>` : ''
|
||||
}
|
||||
|
||||
function getVideo($, item, { staticProxy, index }) {
|
||||
const video = $(item).find('.tgme_widget_message_video_wrap video')
|
||||
video?.attr('src', staticProxy + video?.attr('src'))
|
||||
?.attr('controls', true)
|
||||
?.attr('preload', index > 15 ? 'auto' : 'metadata')
|
||||
?.attr('playsinline', true)
|
||||
.attr('webkit-playsinline', true)
|
||||
|
||||
const roundVideo = $(item).find('.tgme_widget_message_roundvideo_wrap video')
|
||||
roundVideo?.attr('src', staticProxy + roundVideo?.attr('src'))
|
||||
?.attr('controls', true)
|
||||
?.attr('preload', index > 15 ? 'auto' : 'metadata')
|
||||
?.attr('playsinline', true)
|
||||
.attr('webkit-playsinline', true)
|
||||
return $.html(video) + $.html(roundVideo)
|
||||
}
|
||||
|
||||
function getAudio($, item, { staticProxy }) {
|
||||
const audio = $(item).find('.tgme_widget_message_voice')
|
||||
audio?.attr('src', staticProxy + audio?.attr('src'))
|
||||
?.attr('controls', true)
|
||||
return $.html(audio)
|
||||
}
|
||||
|
||||
function getLinkPreview($, item, { staticProxy, index }) {
|
||||
const link = $(item).find('.tgme_widget_message_link_preview')
|
||||
const title = $(item).find('.link_preview_title')?.text() || $(item).find('.link_preview_site_name')?.text()
|
||||
const description = $(item).find('.link_preview_description')?.text()
|
||||
|
||||
link?.attr('target', '_blank').attr('rel', 'noopener').attr('title', description)
|
||||
|
||||
const image = $(item).find('.link_preview_image')
|
||||
const src = image?.attr('style')?.match(/url\(["'](.*?)["']/i)?.[1]
|
||||
const imageSrc = src ? staticProxy + src : ''
|
||||
image?.replaceWith(`<img class="link_preview_image" alt="${title}" src="${imageSrc}" loading="${index > 15 ? 'eager' : 'lazy'}" />`)
|
||||
return $.html(link)
|
||||
}
|
||||
|
||||
function getReply($, item, { channel }) {
|
||||
const reply = $(item).find('.tgme_widget_message_reply')
|
||||
reply?.wrapInner('<small></small>')?.wrapInner('<blockquote></blockquote>')
|
||||
|
||||
const href = reply?.attr('href')
|
||||
if (href) {
|
||||
const url = new URL(href)
|
||||
reply?.attr('href', `${url.pathname}`.replace(new RegExp(`/${channel}/`, 'i'), '/posts/'))
|
||||
}
|
||||
|
||||
return $.html(reply)
|
||||
}
|
||||
|
||||
async function modifyHTMLContent($, content, { index, staticProxy } = {}) {
|
||||
await hydrateTgEmoji($, content, { staticProxy })
|
||||
$(content).find('.emoji')?.removeAttr('style')
|
||||
$(content).find('a')?.each((_index, a) => {
|
||||
$(a)?.attr('title', $(a)?.text())?.removeAttr('onclick')
|
||||
})
|
||||
// Transform Telegram expandable quotes
|
||||
$(content).find('blockquote[expandable]')?.each((_index, bq) => {
|
||||
const innerHTML = $(bq).html()
|
||||
const id = `expand-${index}-${_index}`
|
||||
const expandable = `<div class="tg-expandable">
|
||||
<input type="checkbox" id="${id}" class="tg-expandable__checkbox">
|
||||
<div class="tg-expandable__content">${innerHTML}</div>
|
||||
<label for="${id}" class="tg-expandable__toggle" aria-label="Expand/Collapse"></label>
|
||||
</div>`
|
||||
$(bq).replaceWith(expandable)
|
||||
})
|
||||
$(content).find('tg-spoiler')?.each((_index, spoiler) => {
|
||||
const id = `spoiler-${index}-${_index}`
|
||||
$(spoiler)?.attr('id', id)?.wrap('<label class="spoiler-button"></label>')?.before(`<input type="checkbox" />`)
|
||||
})
|
||||
$(content).find('pre').each((_index, pre) => {
|
||||
try {
|
||||
$(pre).find('br')?.replaceWith('\n')
|
||||
|
||||
const code = $(pre).text()
|
||||
const language = flourite(code, { shiki: true, noUnknown: true })?.language || 'text'
|
||||
const highlightedCode = prism.highlight(code, prism.languages[language], language)
|
||||
$(pre).html(`<code class="language-${language}">${highlightedCode}</code>`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
return content
|
||||
}
|
||||
|
||||
function getReactions($, item, staticProxy) {
|
||||
const reactions = []
|
||||
const reactionNodes = $(item).find('.tgme_widget_message_reactions .tgme_reaction').toArray()
|
||||
|
||||
for (const reaction of reactionNodes) {
|
||||
const isPaid = $(reaction).hasClass('tgme_reaction_paid')
|
||||
let emoji = ''
|
||||
let emojiId
|
||||
let emojiImage
|
||||
|
||||
const standardEmoji = $(reaction).find('.emoji b')
|
||||
if (standardEmoji.length) {
|
||||
emoji = normalizeEmoji(standardEmoji.text().trim())
|
||||
}
|
||||
|
||||
const tgEmoji = $(reaction).find('tg-emoji')
|
||||
if (tgEmoji.length && !emoji) {
|
||||
emojiId = tgEmoji.attr('emoji-id')
|
||||
if (emojiId) {
|
||||
const imageUrl = getCustomEmojiImage(emojiId, staticProxy)
|
||||
if (imageUrl) {
|
||||
emojiImage = imageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPaid && !emoji && !emojiImage) {
|
||||
emoji = '\u2B50'
|
||||
}
|
||||
|
||||
const clone = $(reaction).clone()
|
||||
clone.find('.emoji, tg-emoji, i').remove()
|
||||
const count = clone.text().trim()
|
||||
|
||||
if (count) {
|
||||
reactions.push({
|
||||
emoji,
|
||||
emojiId,
|
||||
emojiImage,
|
||||
count,
|
||||
isPaid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return reactions
|
||||
}
|
||||
|
||||
async function getPost($, item, { channel, staticProxy, index = 0, reactionsEnabled } = {}) {
|
||||
item = item ? $(item).find('.tgme_widget_message') : $('.tgme_widget_message')
|
||||
const content = $(item).find('.js-message_reply_text')?.length > 0
|
||||
? await modifyHTMLContent($, $(item).find('.tgme_widget_message_text.js-message_text'), { index, staticProxy })
|
||||
: await modifyHTMLContent($, $(item).find('.tgme_widget_message_text'), { index, staticProxy })
|
||||
const title = content?.text()?.match(/^.*?(?=[。\n]|http\S)/g)?.[0] ?? content?.text() ?? ''
|
||||
const id = $(item).attr('data-post')?.replace(new RegExp(`${channel}/`, 'i'), '')
|
||||
|
||||
const tags = $(content).find('a[href^="?q="]')?.each((_index, a) => {
|
||||
$(a)?.attr('href', `/search/${encodeURIComponent($(a)?.text())}`)
|
||||
})?.map((_index, a) => $(a)?.text()?.replace('#', ''))?.get()
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type: $(item).attr('class')?.includes('service_message') ? 'service' : 'text',
|
||||
datetime: $(item).find('.tgme_widget_message_date time')?.attr('datetime'),
|
||||
tags,
|
||||
text: content?.text(),
|
||||
content: [
|
||||
getReply($, item, { channel }),
|
||||
getImages($, item, { staticProxy, id, index, title }),
|
||||
getVideo($, item, { staticProxy, id, index, title }),
|
||||
getAudio($, item, { staticProxy, id, index, title }),
|
||||
content?.html(),
|
||||
getImageStickers($, item, { staticProxy, index }),
|
||||
getVideoStickers($, item, { staticProxy, index }),
|
||||
// $(item).find('.tgme_widget_message_sticker_wrap')?.html(),
|
||||
$(item).find('.tgme_widget_message_poll')?.html(),
|
||||
$.html($(item).find('.tgme_widget_message_document_wrap')),
|
||||
$.html($(item).find('.tgme_widget_message_video_player.not_supported')),
|
||||
$.html($(item).find('.tgme_widget_message_location_wrap')),
|
||||
getLinkPreview($, item, { staticProxy, index }),
|
||||
].filter(Boolean).join('').replace(/(url\(["'])((https?:)?\/\/)/g, (match, p1, p2, _p3) => {
|
||||
if (p2 === '//') {
|
||||
p2 = 'https://'
|
||||
}
|
||||
if (p2?.startsWith('t.me')) {
|
||||
return false
|
||||
}
|
||||
return `${p1}${staticProxy}${p2}`
|
||||
}),
|
||||
reactions: reactionsEnabled ? getReactions($, item, staticProxy) : [],
|
||||
}
|
||||
}
|
||||
|
||||
const unnessaryHeaders = ['host', 'cookie', 'origin', 'referer']
|
||||
|
||||
export async function getChannelInfo(Astro, { before = '', after = '', q = '', type = 'list', id = '' } = {}) {
|
||||
const cacheKey = JSON.stringify({ before, after, q, type, id })
|
||||
const cachedResult = cache.get(cacheKey)
|
||||
|
||||
if (cachedResult) {
|
||||
console.info('Match Cache', { before, after, q, type, id })
|
||||
return JSON.parse(JSON.stringify(cachedResult))
|
||||
}
|
||||
|
||||
// Where t.me can also be telegram.me, telegram.dog
|
||||
const host = getEnv(import.meta.env, Astro, 'TELEGRAM_HOST') ?? 't.me'
|
||||
const channel = getEnv(import.meta.env, Astro, 'CHANNEL')
|
||||
const staticProxy = getEnv(import.meta.env, Astro, 'STATIC_PROXY') ?? '/static/'
|
||||
const reactionsEnabled = getEnv(import.meta.env, Astro, 'REACTIONS')
|
||||
|
||||
const url = id ? `https://${host}/${channel}/${id}?embed=1&mode=tme` : `https://${host}/s/${channel}`
|
||||
const headers = Object.fromEntries(Astro.request.headers)
|
||||
|
||||
Object.keys(headers).forEach((key) => {
|
||||
if (unnessaryHeaders.includes(key)) {
|
||||
delete headers[key]
|
||||
}
|
||||
})
|
||||
|
||||
console.info('Fetching', url, { before, after, q, type, id })
|
||||
const html = await $fetch(url, {
|
||||
headers,
|
||||
query: {
|
||||
before: before || undefined,
|
||||
after: after || undefined,
|
||||
q: q || undefined,
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 100,
|
||||
})
|
||||
|
||||
const $ = cheerio.load(html, {}, false)
|
||||
if (id) {
|
||||
const post = await getPost($, null, { channel, staticProxy, reactionsEnabled })
|
||||
cache.set(cacheKey, post)
|
||||
return post
|
||||
}
|
||||
const posts = (await Promise.all(
|
||||
$('.tgme_channel_history .tgme_widget_message_wrap')?.map((index, item) => {
|
||||
return getPost($, item, { channel, staticProxy, index, reactionsEnabled })
|
||||
})?.get() ?? [],
|
||||
))?.reverse().filter(post => ['text'].includes(post.type) && post.id && post.content)
|
||||
|
||||
const channelInfo = {
|
||||
posts,
|
||||
title: $('.tgme_channel_info_header_title')?.text(),
|
||||
description: $('.tgme_channel_info_description')?.text(),
|
||||
descriptionHTML: (await modifyHTMLContent($, $('.tgme_channel_info_description'), { staticProxy }))?.html(),
|
||||
avatar: $('.tgme_page_photo_image img')?.attr('src'),
|
||||
}
|
||||
|
||||
cache.set(cacheKey, channelInfo)
|
||||
return channelInfo
|
||||
}
|
||||
582
src/lib/telegram/index.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import type { AnyNode, Cheerio, CheerioAPI } from 'cheerio'
|
||||
import type { ChannelInfo, EnvCapableAstro, GetChannelInfoParams, Post, Reaction } from '../../types'
|
||||
import * as cheerio from 'cheerio'
|
||||
import flourite from 'flourite'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { getEnv } from '../env'
|
||||
import prism from '../prism'
|
||||
|
||||
const STYLE_URL_REGEX = /url\(["'](.*?)["']/i
|
||||
const STYLE_DIMENSION_REGEX = {
|
||||
width: /width:\s*(\d+(?:\.\d+)?)px/i,
|
||||
height: /height:\s*(\d+(?:\.\d+)?)px/i,
|
||||
} as const
|
||||
const STYLE_PADDING_TOP_REGEX = /padding-top:\s*(\d+(?:\.\d+)?)%/i
|
||||
const TITLE_PREVIEW_REGEX = /^.*?(?=[。\n]|http\S)/g
|
||||
const CONTENT_URL_REGEX = /(url\(["'])((https?:)?\/\/)/g
|
||||
const UNNECESSARY_HEADERS = new Set(['host', 'cookie', 'origin', 'referer'])
|
||||
|
||||
type CacheValue = ChannelInfo | Post
|
||||
type MessageSelection = Cheerio<AnyNode>
|
||||
type RequestContext = EnvCapableAstro & { request: Request }
|
||||
|
||||
interface StaticProxyOptions {
|
||||
staticProxy?: string
|
||||
}
|
||||
|
||||
interface IndexedStaticProxyOptions extends StaticProxyOptions {
|
||||
index?: number
|
||||
}
|
||||
|
||||
interface ReplyOptions {
|
||||
channel: string
|
||||
}
|
||||
|
||||
interface MessageAssetOptions extends IndexedStaticProxyOptions {
|
||||
id?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface ExtractPostOptions {
|
||||
channel: string
|
||||
staticProxy: string
|
||||
index?: number
|
||||
reactionsEnabled?: string
|
||||
}
|
||||
|
||||
interface LoadedChannelDocument {
|
||||
$: CheerioAPI
|
||||
channel: string
|
||||
staticProxy: string
|
||||
reactionsEnabled?: string
|
||||
}
|
||||
|
||||
const cache = new LRUCache<string, CacheValue>({
|
||||
ttl: 1000 * 60 * 5,
|
||||
maxSize: 50 * 1024 * 1024,
|
||||
sizeCalculation: item => JSON.stringify(item).length,
|
||||
})
|
||||
|
||||
function cloneCacheValue<T extends CacheValue>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function isChannelInfo(value: CacheValue): value is ChannelInfo {
|
||||
return 'posts' in value
|
||||
}
|
||||
|
||||
function getRequiredEnv(context: RequestContext, name: string): string {
|
||||
const value = getEnv(import.meta.env, context, name)
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeEmoji(emoji: string): string {
|
||||
const emojiMap: Record<string, string> = {
|
||||
'\u2764': '\u2764\uFE0F',
|
||||
'\u263A': '\u263A\uFE0F',
|
||||
'\u2639': '\u2639\uFE0F',
|
||||
'\u2665': '\u2764\uFE0F',
|
||||
}
|
||||
|
||||
return emojiMap[emoji] ?? emoji
|
||||
}
|
||||
|
||||
function getCustomEmojiImage(emojiId: string | undefined, staticProxy = ''): string | null {
|
||||
if (!emojiId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const imageUrl = `https://t.me/i/emoji/${emojiId}.webp`
|
||||
return `${staticProxy}${imageUrl}`
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: string | null | undefined): value is string {
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
function getImageLoading(index: number): 'eager' | 'lazy' {
|
||||
return index > 15 ? 'lazy' : 'eager'
|
||||
}
|
||||
|
||||
function getStyleDimension(style: string | undefined, property: 'width' | 'height'): number | null {
|
||||
const value = style?.match(STYLE_DIMENSION_REGEX[property])?.[1]
|
||||
return value ? Math.round(Number(value)) : null
|
||||
}
|
||||
|
||||
function getImageDimensions(
|
||||
$: CheerioAPI,
|
||||
node: AnyNode,
|
||||
fallback = { width: 1200, height: 1200 },
|
||||
): { width: number, height: number } {
|
||||
const element = $(node)
|
||||
const styles = [
|
||||
element.attr('style'),
|
||||
element.find('i').attr('style'),
|
||||
element.parent().attr('style'),
|
||||
]
|
||||
|
||||
for (const style of styles) {
|
||||
const width = getStyleDimension(style, 'width')
|
||||
const height = getStyleDimension(style, 'height')
|
||||
|
||||
if (width && height) {
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const paddingTop = style?.match(STYLE_PADDING_TOP_REGEX)?.[1]
|
||||
if (width && paddingTop) {
|
||||
return {
|
||||
width,
|
||||
height: Math.round(width * Number(paddingTop) / 100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getRequestHeaders(request: Request): Record<string, string> {
|
||||
const headers = Object.fromEntries(request.headers.entries())
|
||||
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (UNNECESSARY_HEADERS.has(key)) {
|
||||
delete headers[key]
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async function hydrateTgEmoji($: CheerioAPI, content: MessageSelection, options: StaticProxyOptions = {}): Promise<void> {
|
||||
const { staticProxy = '' } = options
|
||||
|
||||
for (const emojiNode of content.find('tg-emoji').toArray()) {
|
||||
const emojiId = $(emojiNode).attr('emoji-id')
|
||||
const imageUrl = getCustomEmojiImage(emojiId, staticProxy)
|
||||
|
||||
if (imageUrl) {
|
||||
$(emojiNode).replaceWith(`<img class="tg-emoji" src="${imageUrl}" alt="" loading="lazy" width="20" height="20" />`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoStickers($: CheerioAPI, message: MessageSelection, options: IndexedStaticProxyOptions): string {
|
||||
const { staticProxy = '', index = 0 } = options
|
||||
const fragments: string[] = []
|
||||
const loading = getImageLoading(index)
|
||||
|
||||
for (const videoNode of message.find('.js-videosticker_video').toArray()) {
|
||||
const videoSrc = $(videoNode).attr('src')
|
||||
const imageSrc = $(videoNode).find('img').attr('src')
|
||||
|
||||
fragments.push(`
|
||||
<div style="background-image: none; width: 256px;">
|
||||
<video src="${videoSrc ? staticProxy + videoSrc : ''}" width="256" height="256" aria-label="Video sticker" preload muted autoplay loop playsinline disablepictureinpicture>
|
||||
<img class="sticker" src="${imageSrc ? staticProxy + imageSrc : ''}" alt="Video sticker" width="256" height="256" loading="${loading}" />
|
||||
</video>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
return fragments.join('')
|
||||
}
|
||||
|
||||
function getImageStickers($: CheerioAPI, message: MessageSelection, options: IndexedStaticProxyOptions): string {
|
||||
const { staticProxy = '', index = 0 } = options
|
||||
const fragments: string[] = []
|
||||
const loading = getImageLoading(index)
|
||||
|
||||
for (const imageNode of message.find('.tgme_widget_message_sticker').toArray()) {
|
||||
const imageSrc = $(imageNode).attr('data-webp')
|
||||
|
||||
fragments.push(
|
||||
`<img class="sticker" src="${imageSrc ? staticProxy + imageSrc : ''}" style="width: 256px;" alt="Sticker" width="256" height="256" loading="${loading}" />`,
|
||||
)
|
||||
}
|
||||
|
||||
return fragments.join('')
|
||||
}
|
||||
|
||||
function getImages($: CheerioAPI, message: MessageSelection, options: MessageAssetOptions): string {
|
||||
const { staticProxy = '', id = '', index = 0, title = '' } = options
|
||||
const fragments: string[] = []
|
||||
const loading = getImageLoading(index)
|
||||
const safeTitle = escapeHtmlAttribute(title || 'Image from post')
|
||||
const safePreviewLabel = escapeHtmlAttribute(title ? `Open image preview: ${title}` : 'Open image preview')
|
||||
const safeCloseLabel = 'Close image preview'
|
||||
|
||||
for (const [photoIndex, photoNode] of message.find('.tgme_widget_message_photo_wrap').toArray().entries()) {
|
||||
const imageUrl = $(photoNode).attr('style')?.match(STYLE_URL_REGEX)?.[1]
|
||||
|
||||
if (!imageUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
const popoverId = `modal-${id}-${photoIndex}`
|
||||
const { width, height } = getImageDimensions($, photoNode)
|
||||
fragments.push(`
|
||||
<button
|
||||
type="button"
|
||||
class="image-preview-button image-preview-wrap"
|
||||
popovertarget="${popoverId}"
|
||||
popovertargetaction="show"
|
||||
aria-label="${safePreviewLabel}"
|
||||
>
|
||||
<img src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="${loading}" />
|
||||
</button>
|
||||
<div class="modal" id="${popoverId}" popover aria-label="Image preview">
|
||||
<button
|
||||
type="button"
|
||||
class="modal__backdrop"
|
||||
popovertarget="${popoverId}"
|
||||
popovertargetaction="hide"
|
||||
aria-label="${safeCloseLabel}"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal__close"
|
||||
popovertarget="${popoverId}"
|
||||
popovertargetaction="hide"
|
||||
aria-label="${safeCloseLabel}"
|
||||
>×</button>
|
||||
<div class="modal__surface">
|
||||
<img class="modal-img" src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
if (!fragments.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const layoutClass = fragments.length % 2 === 0 ? 'image-list-even' : 'image-list-odd'
|
||||
return `<div class="image-list-container ${layoutClass}">${fragments.join('')}</div>`
|
||||
}
|
||||
|
||||
function getVideo($: CheerioAPI, message: MessageSelection, options: IndexedStaticProxyOptions): string {
|
||||
const { staticProxy = '', index = 0 } = options
|
||||
const video = message.find('.tgme_widget_message_video_wrap video')
|
||||
const videoSrc = video.attr('src')
|
||||
|
||||
if (videoSrc) {
|
||||
video.attr('src', staticProxy + videoSrc)
|
||||
}
|
||||
|
||||
video
|
||||
.attr('controls', '')
|
||||
.attr('preload', index > 15 ? 'metadata' : 'auto')
|
||||
.attr('playsinline', '')
|
||||
.attr('webkit-playsinline', '')
|
||||
|
||||
const roundVideo = message.find('.tgme_widget_message_roundvideo_wrap video')
|
||||
const roundVideoSrc = roundVideo.attr('src')
|
||||
|
||||
if (roundVideoSrc) {
|
||||
roundVideo.attr('src', staticProxy + roundVideoSrc)
|
||||
}
|
||||
|
||||
roundVideo
|
||||
.attr('controls', '')
|
||||
.attr('preload', index > 15 ? 'metadata' : 'auto')
|
||||
.attr('playsinline', '')
|
||||
.attr('webkit-playsinline', '')
|
||||
|
||||
return $.html(video) + $.html(roundVideo)
|
||||
}
|
||||
|
||||
function getAudio($: CheerioAPI, message: MessageSelection, options: StaticProxyOptions): string {
|
||||
const { staticProxy = '' } = options
|
||||
const audio = message.find('.tgme_widget_message_voice')
|
||||
const audioSrc = audio.attr('src')
|
||||
|
||||
if (audioSrc) {
|
||||
audio.attr('src', staticProxy + audioSrc)
|
||||
}
|
||||
|
||||
audio.attr('controls', '')
|
||||
return $.html(audio)
|
||||
}
|
||||
|
||||
function getLinkPreview($: CheerioAPI, message: MessageSelection, options: IndexedStaticProxyOptions): string {
|
||||
const { staticProxy = '', index = 0 } = options
|
||||
const link = message.find('.tgme_widget_message_link_preview')
|
||||
const title = message.find('.link_preview_title').text() || message.find('.link_preview_site_name').text()
|
||||
const description = message.find('.link_preview_description').text()
|
||||
const loading = getImageLoading(index)
|
||||
const safeTitle = escapeHtmlAttribute(title || 'Link preview image')
|
||||
|
||||
link.attr('target', '_blank').attr('rel', 'noopener').attr('title', description)
|
||||
|
||||
const image = message.find('.link_preview_image')
|
||||
const previewUrl = image.attr('style')?.match(STYLE_URL_REGEX)?.[1]
|
||||
const imageSrc = previewUrl ? staticProxy + previewUrl : ''
|
||||
|
||||
image.replaceWith(
|
||||
`<img class="link_preview_image" alt="${safeTitle}" src="${imageSrc}" width="1200" height="630" loading="${loading}" />`,
|
||||
)
|
||||
|
||||
return $.html(link)
|
||||
}
|
||||
|
||||
function getReply($: CheerioAPI, message: MessageSelection, options: ReplyOptions): string {
|
||||
const { channel } = options
|
||||
const reply = message.find('.tgme_widget_message_reply')
|
||||
|
||||
reply.wrapInner('<small></small>').wrapInner('<blockquote></blockquote>')
|
||||
|
||||
const href = reply.attr('href')
|
||||
if (href) {
|
||||
const replyUrl = new URL(href, 'https://t.me')
|
||||
reply.attr('href', replyUrl.pathname.replace(new RegExp(`/${channel}/`, 'i'), '/posts/'))
|
||||
}
|
||||
|
||||
return $.html(reply)
|
||||
}
|
||||
|
||||
async function modifyHTMLContent($: CheerioAPI, content: MessageSelection, options: IndexedStaticProxyOptions = {}): Promise<MessageSelection> {
|
||||
const { index = 0, staticProxy = '' } = options
|
||||
|
||||
await hydrateTgEmoji($, content, { staticProxy })
|
||||
content.find('.emoji').removeAttr('style')
|
||||
|
||||
for (const linkNode of content.find('a').toArray()) {
|
||||
const link = $(linkNode)
|
||||
link.attr('title', link.text()).removeAttr('onclick')
|
||||
}
|
||||
|
||||
for (const [blockquoteIndex, blockquoteNode] of content.find('blockquote[expandable]').toArray().entries()) {
|
||||
const innerHTML = $(blockquoteNode).html() ?? ''
|
||||
const expandId = `expand-${index}-${blockquoteIndex}`
|
||||
const expandContentId = `${expandId}-content`
|
||||
const expandable = `<div class="tg-expandable">
|
||||
<input type="checkbox" id="${expandId}" class="tg-expandable__checkbox" aria-label="Expand hidden content" aria-controls="${expandContentId}">
|
||||
<div id="${expandContentId}" class="tg-expandable__content">${innerHTML}</div>
|
||||
<label for="${expandId}" class="tg-expandable__toggle"><span class="sr-only">Expand hidden content</span></label>
|
||||
</div>`
|
||||
|
||||
$(blockquoteNode).replaceWith(expandable)
|
||||
}
|
||||
|
||||
for (const [spoilerIndex, spoilerNode] of content.find('tg-spoiler').toArray().entries()) {
|
||||
const spoiler = $(spoilerNode)
|
||||
const spoilerId = `spoiler-${index}-${spoilerIndex}`
|
||||
const spoilerInput = `<input type="checkbox" aria-label="Reveal spoiler" aria-controls="${spoilerId}" />`
|
||||
|
||||
spoiler.attr('id', spoilerId).wrap('<label class="spoiler-button"></label>').before(spoilerInput)
|
||||
}
|
||||
|
||||
for (const preNode of content.find('pre').toArray()) {
|
||||
try {
|
||||
const pre = $(preNode)
|
||||
pre.find('br').replaceWith('\n')
|
||||
|
||||
const code = pre.text()
|
||||
const language = flourite(code, { shiki: true, noUnknown: true }).language || 'text'
|
||||
const highlightedCode = prism.highlight(code, prism.languages[language], language)
|
||||
pre.html(`<code class="language-${language}">${highlightedCode}</code>`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function getReactions($: CheerioAPI, message: MessageSelection, staticProxy: string): Reaction[] {
|
||||
const reactions: Reaction[] = []
|
||||
|
||||
for (const reactionNode of message.find('.tgme_widget_message_reactions .tgme_reaction').toArray()) {
|
||||
const reaction = $(reactionNode)
|
||||
const isPaid = reaction.hasClass('tgme_reaction_paid')
|
||||
let emoji = ''
|
||||
let emojiId: string | undefined
|
||||
let emojiImage: string | undefined
|
||||
|
||||
const standardEmoji = reaction.find('.emoji b')
|
||||
if (standardEmoji.length) {
|
||||
emoji = normalizeEmoji(standardEmoji.text().trim())
|
||||
}
|
||||
|
||||
const tgEmoji = reaction.find('tg-emoji')
|
||||
if (tgEmoji.length && !emoji) {
|
||||
emojiId = tgEmoji.attr('emoji-id')
|
||||
const customEmojiImage = getCustomEmojiImage(emojiId, staticProxy)
|
||||
if (customEmojiImage) {
|
||||
emojiImage = customEmojiImage
|
||||
}
|
||||
}
|
||||
|
||||
if (isPaid && !emoji && !emojiImage) {
|
||||
emoji = '\u2B50'
|
||||
}
|
||||
|
||||
const clone = reaction.clone()
|
||||
clone.find('.emoji, tg-emoji, i').remove()
|
||||
const count = clone.text().trim()
|
||||
|
||||
if (count) {
|
||||
reactions.push({
|
||||
emoji,
|
||||
emojiId,
|
||||
emojiImage,
|
||||
count,
|
||||
isPaid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return reactions
|
||||
}
|
||||
|
||||
async function extractPost($: CheerioAPI, item: AnyNode | null, options: ExtractPostOptions): Promise<Post> {
|
||||
const { channel, staticProxy, index = 0, reactionsEnabled } = options
|
||||
const message = item ? $(item).find('.tgme_widget_message') : $('.tgme_widget_message')
|
||||
const hasReplyText = message.find('.js-message_reply_text').length > 0
|
||||
const content = await modifyHTMLContent(
|
||||
$,
|
||||
message.find(hasReplyText ? '.tgme_widget_message_text.js-message_text' : '.tgme_widget_message_text'),
|
||||
{ index, staticProxy },
|
||||
)
|
||||
const contentText = content.text()
|
||||
const title = contentText.match(TITLE_PREVIEW_REGEX)?.[0] ?? contentText
|
||||
const id = message.attr('data-post')?.replace(new RegExp(`${channel}/`, 'i'), '') ?? ''
|
||||
const tags: string[] = []
|
||||
|
||||
for (const tagNode of content.find('a[href^="?q="]').toArray()) {
|
||||
const tagLink = $(tagNode)
|
||||
const tagText = tagLink.text()
|
||||
|
||||
tagLink.attr('href', `/search/${encodeURIComponent(tagText)}`)
|
||||
|
||||
const normalizedTag = tagText.replace('#', '')
|
||||
if (normalizedTag) {
|
||||
tags.push(normalizedTag)
|
||||
}
|
||||
}
|
||||
|
||||
const contentHtml = [
|
||||
getReply($, message, { channel }),
|
||||
getImages($, message, { staticProxy, id, index, title }),
|
||||
getVideo($, message, { staticProxy, index }),
|
||||
getAudio($, message, { staticProxy }),
|
||||
content.html(),
|
||||
getImageStickers($, message, { staticProxy, index }),
|
||||
getVideoStickers($, message, { staticProxy, index }),
|
||||
message.find('.tgme_widget_message_poll').html(),
|
||||
$.html(message.find('.tgme_widget_message_document_wrap')),
|
||||
$.html(message.find('.tgme_widget_message_video_player.not_supported')),
|
||||
$.html(message.find('.tgme_widget_message_location_wrap')),
|
||||
getLinkPreview($, message, { staticProxy, index }),
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.join('')
|
||||
.replace(CONTENT_URL_REGEX, (_match, prefix: string, protocol: string) => {
|
||||
const normalizedProtocol = protocol === '//' ? 'https://' : protocol
|
||||
return `${prefix}${staticProxy}${normalizedProtocol}`
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type: message.attr('class')?.includes('service_message') ? 'service' : 'text',
|
||||
datetime: message.find('.tgme_widget_message_date time').attr('datetime') ?? '',
|
||||
tags,
|
||||
text: contentText,
|
||||
content: contentHtml,
|
||||
reactions: reactionsEnabled ? getReactions($, message, staticProxy) : [],
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChannelDocument(
|
||||
context: RequestContext,
|
||||
params: GetChannelInfoParams & { id?: string } = {},
|
||||
): Promise<LoadedChannelDocument> {
|
||||
const { before, after, q, id } = params
|
||||
const host = getEnv(import.meta.env, context, 'TELEGRAM_HOST') ?? 't.me'
|
||||
const channel = getRequiredEnv(context, 'CHANNEL')
|
||||
const staticProxy = getEnv(import.meta.env, context, 'STATIC_PROXY') ?? '/static/'
|
||||
const reactionsEnabled = getEnv(import.meta.env, context, 'REACTIONS')
|
||||
const requestUrl = id
|
||||
? `https://${host}/${channel}/${id}?embed=1&mode=tme`
|
||||
: `https://${host}/s/${channel}`
|
||||
|
||||
console.info('Fetching', requestUrl, { before, after, q, id })
|
||||
|
||||
const html = await $fetch<string>(requestUrl, {
|
||||
headers: getRequestHeaders(context.request),
|
||||
query: {
|
||||
before: before || undefined,
|
||||
after: after || undefined,
|
||||
q: q || undefined,
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 100,
|
||||
})
|
||||
|
||||
return {
|
||||
$: cheerio.load(html, {}, false),
|
||||
channel,
|
||||
staticProxy,
|
||||
reactionsEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChannelPost(context: RequestContext, id: string): Promise<Post> {
|
||||
const cacheKey = JSON.stringify({ scope: 'post', id })
|
||||
const cachedResult = cache.get(cacheKey)
|
||||
|
||||
if (cachedResult && !isChannelInfo(cachedResult)) {
|
||||
console.info('Match Cache', { id })
|
||||
return cloneCacheValue(cachedResult)
|
||||
}
|
||||
|
||||
const { $, channel, staticProxy, reactionsEnabled } = await loadChannelDocument(context, { id })
|
||||
const post = await extractPost($, null, { channel, staticProxy, reactionsEnabled })
|
||||
|
||||
cache.set(cacheKey, post)
|
||||
return cloneCacheValue(post)
|
||||
}
|
||||
|
||||
export async function getChannelInfo(context: RequestContext, params: GetChannelInfoParams = {}): Promise<ChannelInfo> {
|
||||
const { before = '', after = '', q = '' } = params
|
||||
const cacheKey = JSON.stringify({ scope: 'channel', before, after, q })
|
||||
const cachedResult = cache.get(cacheKey)
|
||||
|
||||
if (cachedResult && isChannelInfo(cachedResult)) {
|
||||
console.info('Match Cache', { before, after, q })
|
||||
return cloneCacheValue(cachedResult)
|
||||
}
|
||||
|
||||
const { $, channel, staticProxy, reactionsEnabled } = await loadChannelDocument(context, { before, after, q })
|
||||
const postNodes = $('.tgme_channel_history .tgme_widget_message_wrap').toArray()
|
||||
const posts = (await Promise.all(
|
||||
postNodes.map((item, index) => extractPost($, item, { channel, staticProxy, index, reactionsEnabled })),
|
||||
))
|
||||
.reverse()
|
||||
.filter(post => post.type === 'text' && Boolean(post.id) && Boolean(post.content))
|
||||
|
||||
const channelInfo: ChannelInfo = {
|
||||
posts,
|
||||
title: $('.tgme_channel_info_header_title').text(),
|
||||
description: $('.tgme_channel_info_description').text(),
|
||||
descriptionHTML: (await modifyHTMLContent($, $('.tgme_channel_info_description'), { staticProxy })).html(),
|
||||
avatar: $('.tgme_page_photo_image img').attr('src'),
|
||||
}
|
||||
|
||||
cache.set(cacheKey, channelInfo)
|
||||
return cloneCacheValue(channelInfo)
|
||||
}
|
||||
4
src/lib/ui.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const siteTitleStyle = {
|
||||
'view-transition-name': 'site-title',
|
||||
'transition': '0.2s ease',
|
||||
} as const
|
||||
@@ -1,4 +1,6 @@
|
||||
export async function onRequest(context, next) {
|
||||
import { defineMiddleware } from 'astro:middleware'
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
context.locals.SITE_URL = `${import.meta.env.SITE ?? ''}${import.meta.env.BASE_URL}`
|
||||
context.locals.RSS_URL = `${context.locals.SITE_URL}rss.xml`
|
||||
context.locals.RSS_PREFIX = ''
|
||||
@@ -21,4 +23,4 @@ export async function onRequest(context, next) {
|
||||
}
|
||||
}
|
||||
return response
|
||||
};
|
||||
})
|
||||
@@ -11,4 +11,4 @@ channel.seo = {
|
||||
}
|
||||
---
|
||||
|
||||
<List channel={channel} />
|
||||
<List channel={channel} pageHeading={channel.title} />
|
||||
|
||||
@@ -11,4 +11,4 @@ channel.seo = {
|
||||
}
|
||||
---
|
||||
|
||||
<List channel={channel} />
|
||||
<List channel={channel} pageHeading={channel.title} />
|
||||
|
||||
@@ -5,4 +5,4 @@ import { getChannelInfo } from '../lib/telegram'
|
||||
const channel = await getChannelInfo(Astro)
|
||||
---
|
||||
|
||||
<List channel={channel} after={false} />
|
||||
<List channel={channel} after={false} pageHeading={channel.title} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Layout from '../layouts/base.astro'
|
||||
import Header from '../components/header.astro'
|
||||
import TagCloudSection from '../components/TagCloudSection.astro'
|
||||
import { getChannelInfo } from '../lib/telegram'
|
||||
import { getEnv } from '../lib/env'
|
||||
|
||||
@@ -24,24 +25,17 @@ const links = (getEnv(import.meta.env, Astro, 'LINKS') || '')
|
||||
if (!links.length) {
|
||||
return Astro.redirect('/')
|
||||
}
|
||||
|
||||
const items = links.map((link) => ({
|
||||
href: link.href,
|
||||
label: link.title,
|
||||
title: link.title,
|
||||
external: true,
|
||||
}))
|
||||
---
|
||||
|
||||
<Layout channel={channel} id="main-container">
|
||||
<slot name="header">
|
||||
<Header channel={channel} />
|
||||
</slot>
|
||||
<Layout channel={channel}>
|
||||
<Header channel={channel} />
|
||||
|
||||
<div class="section-title">Links</div>
|
||||
|
||||
<div class="tag-cloud">
|
||||
{
|
||||
links.map((link) => (
|
||||
<div class="tag-cloud-item">
|
||||
<a href={link.href} class="tag" target="_blank" rel="noopener" title={link.title}>
|
||||
{link.title}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<TagCloudSection title="Links" items={items} />
|
||||
</Layout>
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
import { getEnv } from '../../lib/env'
|
||||
import voidFile from '../../assets/void.png'
|
||||
import List from '../../components/list.astro'
|
||||
import { getChannelInfo } from '../../lib/telegram'
|
||||
import { getChannelInfo, getChannelPost } from '../../lib/telegram'
|
||||
import { siteTitleStyle } from '../../lib/ui'
|
||||
|
||||
const { SITE_URL } = Astro.locals
|
||||
|
||||
const channelInfo = await getChannelInfo(Astro)
|
||||
|
||||
const post = await getChannelInfo(Astro, {
|
||||
type: 'post',
|
||||
id: Astro.params.id,
|
||||
})
|
||||
if (!Astro.params.id) {
|
||||
return Astro.redirect('/')
|
||||
}
|
||||
|
||||
const post = await getChannelPost(Astro, Astro.params.id)
|
||||
|
||||
const channel = {
|
||||
...(channelInfo || {}),
|
||||
@@ -20,20 +22,29 @@ const channel = {
|
||||
}
|
||||
|
||||
const staticProxy = getEnv(import.meta.env, Astro, 'STATIC_PROXY') ?? '/static/'
|
||||
const breadcrumbClass = 'mb-5 py-[10px] font-semibold'
|
||||
const breadcrumbListClass = 'm-0 flex list-none items-center p-0'
|
||||
const breadcrumbLinkClass = 'inline-flex items-center no-underline'
|
||||
const breadcrumbAvatarClass = 'block h-5 w-5 rounded-full border-2 border-white object-cover shadow-soft'
|
||||
const breadcrumbTitleClass = 'ml-[10px] flex-1 text-[14px] text-heading'
|
||||
---
|
||||
|
||||
<List channel={channel} before={false} after={false} isItem={true}>
|
||||
<div slot="header" id="breadcrumb">
|
||||
<img
|
||||
src={channel?.avatar?.startsWith('http') ? staticProxy + channel?.avatar : voidFile.src}
|
||||
alt={channel?.title}
|
||||
loading="eager"
|
||||
class="breadcrumb-avatar"
|
||||
/>
|
||||
<div class="breadcrumb-title">
|
||||
<a href={SITE_URL} class="site-title" title={channel?.title}>
|
||||
{channel.title}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<List channel={channel} before={false} after={false} isItem={true} pageHeading={post.title || channel.title}>
|
||||
<nav slot="header" class={breadcrumbClass} aria-label="Breadcrumb">
|
||||
<ol class={breadcrumbListClass}>
|
||||
<li>
|
||||
<a href={SITE_URL} class={breadcrumbLinkClass} title={channel?.title}>
|
||||
<img
|
||||
src={channel?.avatar?.startsWith('http') ? staticProxy + channel?.avatar : voidFile.src}
|
||||
alt={channel?.title}
|
||||
loading="eager"
|
||||
width="20"
|
||||
height="20"
|
||||
class={breadcrumbAvatarClass}
|
||||
/>
|
||||
<span class={breadcrumbTitleClass} style={siteTitleStyle}>{channel.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</List>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { getChannelInfo } from '../lib/telegram'
|
||||
|
||||
export async function GET(Astro) {
|
||||
const { SITE_URL } = Astro.locals
|
||||
const tag = Astro.url.searchParams.get('tag')
|
||||
const channel = await getChannelInfo(Astro, {
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { SITE_URL } = context.locals
|
||||
const tag = context.url.searchParams.get('tag')
|
||||
const channel = await getChannelInfo(context, {
|
||||
q: tag ? `#${tag}` : '',
|
||||
})
|
||||
const posts = channel.posts || []
|
||||
const posts = channel.posts ?? []
|
||||
const requestUrl = new URL(context.request.url)
|
||||
|
||||
const request = Astro.request
|
||||
const url = new URL(request.url)
|
||||
url.pathname = SITE_URL
|
||||
url.search = ''
|
||||
requestUrl.pathname = SITE_URL
|
||||
requestUrl.search = ''
|
||||
|
||||
return Response.json({
|
||||
version: 'https://jsonfeed.org/version/1.1',
|
||||
title: `${tag ? `${tag} | ` : ''}${channel.title}`,
|
||||
description: channel.description,
|
||||
home_page_url: url.toString(),
|
||||
home_page_url: requestUrl.toString(),
|
||||
items: posts.map(item => ({
|
||||
url: `${url.toString()}posts/${item.id}`,
|
||||
url: `${requestUrl.toString()}posts/${item.id}`,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
date_published: new Date(item.datetime),
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import rss from '@astrojs/rss'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import { getEnv } from '../lib/env'
|
||||
import { getChannelInfo } from '../lib/telegram'
|
||||
|
||||
export async function GET(Astro) {
|
||||
const { SITE_URL } = Astro.locals
|
||||
const tag = Astro.url.searchParams.get('tag')
|
||||
const channel = await getChannelInfo(Astro, {
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { SITE_URL } = context.locals
|
||||
const tag = context.url.searchParams.get('tag')
|
||||
const channel = await getChannelInfo(context, {
|
||||
q: tag ? `#${tag}` : '',
|
||||
})
|
||||
const posts = channel.posts || []
|
||||
const posts = channel.posts ?? []
|
||||
const requestUrl = new URL(context.request.url)
|
||||
|
||||
const request = Astro.request
|
||||
const url = new URL(request.url)
|
||||
url.pathname = SITE_URL
|
||||
url.search = ''
|
||||
requestUrl.pathname = SITE_URL
|
||||
requestUrl.search = ''
|
||||
|
||||
const response = await rss({
|
||||
title: `${tag ? `${tag} | ` : ''}${channel.title}`,
|
||||
description: channel.description,
|
||||
site: url.origin,
|
||||
site: requestUrl.origin,
|
||||
trailingSlash: false,
|
||||
stylesheet: getEnv(import.meta.env, Astro, 'RSS_BEAUTIFY') ? '/rss.xsl' : undefined,
|
||||
stylesheet: getEnv(import.meta.env, context, 'RSS_BEAUTIFY') ? '/rss.xsl' : undefined,
|
||||
items: posts.map(item => ({
|
||||
link: `posts/${item.id}`,
|
||||
title: item.title,
|
||||
@@ -36,7 +36,7 @@ export async function GET(Astro) {
|
||||
img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading', 'class'],
|
||||
},
|
||||
exclusiveFilter(frame) {
|
||||
return frame.tag === 'img' && frame.attribs?.class?.includes('modal-img')
|
||||
return frame.tag === 'img' && frame.attribs.class?.includes('modal-img')
|
||||
},
|
||||
}),
|
||||
})),
|
||||
@@ -1,4 +1,6 @@
|
||||
export async function GET() {
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
return Response.json({
|
||||
prerender: [
|
||||
{
|
||||
@@ -9,7 +9,8 @@ const channel = await getChannelInfo(Astro, {
|
||||
|
||||
channel.seo = {
|
||||
title: `${q}`,
|
||||
noindex: true,
|
||||
}
|
||||
---
|
||||
|
||||
<List channel={channel} before={false} after={false} />
|
||||
<List channel={channel} before={false} after={false} pageHeading={`Search: ${q}`} />
|
||||
|
||||
59
src/pages/site.webmanifest.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { getEnv } from '../lib/env'
|
||||
import { getChannelInfo } from '../lib/telegram'
|
||||
|
||||
const MANIFEST_THEME_COLOR = '#f4f1ec'
|
||||
const FALLBACK_MANIFEST_NAME = 'BroadcastChannel'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { SITE_URL } = context.locals
|
||||
const channel = await getChannelInfo(context)
|
||||
const absoluteSiteUrl = SITE_URL.startsWith('http') ? SITE_URL : new URL(SITE_URL, context.url.origin).toString()
|
||||
const staticProxy = getEnv(import.meta.env, context, 'STATIC_PROXY') ?? '/static/'
|
||||
const siteName = channel.title || FALLBACK_MANIFEST_NAME
|
||||
const avatarIcon = channel.avatar?.startsWith('http')
|
||||
? new URL(`${staticProxy}${channel.avatar}`, absoluteSiteUrl).toString()
|
||||
: null
|
||||
|
||||
const manifest = {
|
||||
name: siteName,
|
||||
short_name: siteName,
|
||||
icons: avatarIcon
|
||||
? [
|
||||
{
|
||||
src: avatarIcon,
|
||||
sizes: '192x192',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
{
|
||||
src: avatarIcon,
|
||||
sizes: '512x512',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
src: 'favicon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: 'favicon.ico',
|
||||
sizes: '16x16 32x32 48x48',
|
||||
type: 'image/x-icon',
|
||||
purpose: 'any',
|
||||
},
|
||||
],
|
||||
theme_color: MANIFEST_THEME_COLOR,
|
||||
background_color: MANIFEST_THEME_COLOR,
|
||||
display: 'standalone',
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(manifest, null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { getChannelInfo } from '../lib/telegram'
|
||||
|
||||
export async function GET(Astro) {
|
||||
export const GET: APIRoute = async (Astro) => {
|
||||
const request = Astro.request
|
||||
const url = new URL(request.url)
|
||||
const channel = await getChannelInfo(Astro)
|
||||
@@ -9,7 +10,7 @@ export async function GET(Astro) {
|
||||
const pageSize = 20
|
||||
let count = +posts[0]?.id
|
||||
|
||||
const pages = []
|
||||
const pages: number[] = []
|
||||
pages.push(count)
|
||||
while (count > pageSize) {
|
||||
count -= pageSize
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { getChannelInfo } from '../../lib/telegram'
|
||||
|
||||
export async function GET(Astro) {
|
||||
export const GET: APIRoute = async (Astro) => {
|
||||
const request = Astro.request
|
||||
const url = new URL(request.url)
|
||||
const channel = await getChannelInfo(Astro, {
|
||||
@@ -1,25 +0,0 @@
|
||||
const targetWhitelist = [
|
||||
't.me',
|
||||
'telegram.org',
|
||||
'telegram.me',
|
||||
'telegram.dog',
|
||||
'cdn-telegram.org',
|
||||
'telesco.pe',
|
||||
'yandex.ru',
|
||||
]
|
||||
|
||||
export async function GET({ request, params, url }) {
|
||||
try {
|
||||
const rawTarget = params.url + url.search
|
||||
const normalizedTarget = rawTarget.startsWith('//') ? `https:${rawTarget}` : rawTarget
|
||||
const target = new URL(normalizedTarget)
|
||||
if (!targetWhitelist.some(domain => target.hostname.endsWith(domain))) {
|
||||
return Response.redirect(target.toString(), 302)
|
||||
}
|
||||
const response = await fetch(target.toString(), request)
|
||||
return new Response(response.body, response)
|
||||
}
|
||||
catch (error) {
|
||||
return new Response(error.message, { status: 500 })
|
||||
}
|
||||
}
|
||||
13
src/pages/static/[...url].ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
import { createStaticProxyResponse } from '../../lib/static-proxy'
|
||||
|
||||
export const GET: APIRoute = async ({ request, params, url }) => {
|
||||
try {
|
||||
const rawTarget = (params.url ?? '') + url.search
|
||||
return await createStaticProxyResponse(request, rawTarget)
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return new Response(message, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Layout from '../layouts/base.astro'
|
||||
import Header from '../components/header.astro'
|
||||
import TagCloudSection from '../components/TagCloudSection.astro'
|
||||
import { getChannelInfo } from '../lib/telegram'
|
||||
import { getEnv } from '../lib/env'
|
||||
|
||||
@@ -10,26 +11,14 @@ channel.seo = {
|
||||
title: 'Tags',
|
||||
}
|
||||
|
||||
const tags = (getEnv(import.meta.env, Astro, 'TAGS') || '').split(',')
|
||||
const items = (getEnv(import.meta.env, Astro, 'TAGS') || '').split(',').map((tag) => ({
|
||||
href: `/search/%23${tag}`,
|
||||
label: tag,
|
||||
}))
|
||||
---
|
||||
|
||||
<Layout channel={channel} id="main-container">
|
||||
<slot name="header">
|
||||
<Header channel={channel} />
|
||||
</slot>
|
||||
<Layout channel={channel}>
|
||||
<Header channel={channel} />
|
||||
|
||||
<div class="section-title">Tags</div>
|
||||
|
||||
<div class="tag-cloud">
|
||||
{
|
||||
tags.map((tag: string) => (
|
||||
<div class="tag-cloud-item">
|
||||
<a href={`/search/%23${tag}`} class="tag">
|
||||
{tag}
|
||||
</a>
|
||||
{/* <span class="tag-cloud-item-count">0</span> */}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<TagCloudSection title="Tags" items={items} />
|
||||
</Layout>
|
||||
|
||||
145
src/styles/app.css
Normal file
@@ -0,0 +1,145 @@
|
||||
@import 'tailwindcss';
|
||||
@import './content.css';
|
||||
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--breakpoint-sm: 37.5rem;
|
||||
--color-paper: #f4f1ec;
|
||||
--color-ink: #000000;
|
||||
--color-heading: #333333;
|
||||
--color-accent: #b23b00;
|
||||
--color-surface: #ffffff;
|
||||
--color-code: #f9f9f9;
|
||||
--color-muted: #706862;
|
||||
--color-link: #5a6570;
|
||||
--color-link-hover: #3f4850;
|
||||
--color-line: rgba(0, 0, 0, 0.05);
|
||||
--color-footer: #666666;
|
||||
--shadow-soft:
|
||||
0 1px 2px rgba(0, 0, 0, 0.02), 0 2px 4px rgba(0, 0, 0, 0.02), 0 4px 8px rgba(0, 0, 0, 0.02),
|
||||
0 8px 16px rgba(0, 0, 0, 0.02);
|
||||
--radius-panel: 3px;
|
||||
--radius-media: 8px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--box-margin: 10px;
|
||||
--back-to-top-offset: 1.25rem;
|
||||
--back-to-top-size: 2rem;
|
||||
--dot-size: 8px;
|
||||
--icon-hover-filter: invert(51%) sepia(0%) saturate(0%) hue-rotate(23deg) brightness(90%) contrast(90%);
|
||||
--icon-secondary-filter: invert(97%) sepia(0%) saturate(0%) hue-rotate(129deg) brightness(86%) contrast(88%);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a:link,
|
||||
a:visited {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
line-break: loose;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-link-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--color-heading);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 2px;
|
||||
color: var(--color-line);
|
||||
background-color: var(--color-line);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.tag-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-size: 16px 16px;
|
||||
opacity: 0.25;
|
||||
background-image: url('../assets/tags.png');
|
||||
background-repeat: no-repeat;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#back-to-top-wrapper {
|
||||
position: absolute;
|
||||
inset-inline-end: var(--back-to-top-offset);
|
||||
top: calc(200vh - var(--back-to-top-size) - var(--back-to-top-offset));
|
||||
bottom: 0;
|
||||
width: var(--back-to-top-size);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#back-to-top {
|
||||
position: fixed;
|
||||
inset-inline-end: var(--back-to-top-offset);
|
||||
bottom: var(--back-to-top-offset);
|
||||
}
|
||||
|
||||
@supports (position: sticky) {
|
||||
#back-to-top {
|
||||
position: sticky;
|
||||
top: calc(100vh - var(--back-to-top-size) - var(--back-to-top-offset));
|
||||
inset-inline-end: auto;
|
||||
bottom: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
641
src/styles/content.css
Normal file
@@ -0,0 +1,641 @@
|
||||
@import 'prismjs/themes/prism.css';
|
||||
|
||||
@layer components {
|
||||
.content {
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 1em 0;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.83em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 1.33em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 1.67em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 2.33em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
ul:first-child,
|
||||
ol:first-child,
|
||||
p:first-child,
|
||||
h1:first-child,
|
||||
h2:first-child,
|
||||
h3:first-child,
|
||||
h4:first-child,
|
||||
h5:first-child,
|
||||
h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
ul:last-child,
|
||||
ol:last-child,
|
||||
p:last-child,
|
||||
h1:last-child,
|
||||
h2:last-child,
|
||||
h3:last-child,
|
||||
h4:last-child,
|
||||
h5:last-child,
|
||||
h6:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
img:not(.tg-emoji):not(.sticker):not(.link_preview_image):not(.modal-img) {
|
||||
width: calc(100% - var(--box-margin));
|
||||
max-width: calc(100% - 1px);
|
||||
max-height: initial;
|
||||
border-radius: var(--radius-media);
|
||||
border: 1px solid var(--color-line);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
a:link,
|
||||
a:visited {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.9em;
|
||||
white-space: pre-wrap;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-media);
|
||||
border: 1px solid var(--color-line);
|
||||
box-shadow: var(--shadow-soft);
|
||||
background-color: rgba(255, 255, 240, 0.2);
|
||||
}
|
||||
|
||||
> pre {
|
||||
width: calc(100% - var(--box-margin));
|
||||
max-width: 456px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--color-footer);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
> iframe {
|
||||
max-width: calc(100% - 1px);
|
||||
border-radius: var(--radius-media);
|
||||
border: 1px solid var(--color-line);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border-collapse: collapse;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
table tr {
|
||||
border-top: 1px solid var(--color-line);
|
||||
background-color: var(--color-surface);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table tr:nth-child(2n) {
|
||||
background-color: var(--color-code);
|
||||
}
|
||||
|
||||
table tr th {
|
||||
font-weight: bold;
|
||||
border: 1px solid var(--color-line);
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--color-code);
|
||||
}
|
||||
|
||||
table tr td {
|
||||
border: 1px solid var(--color-line);
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
line-break: anywhere;
|
||||
}
|
||||
|
||||
table tr th :first-child,
|
||||
table tr td :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
table tr th :last-child,
|
||||
table tr td :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul:has(> li > input[type='checkbox']:disabled) {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
li > input[type='checkbox']:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
li:has(> input[type='checkbox']:disabled) {
|
||||
list-style-type: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
li:has(> input[type='checkbox']:not(:checked):disabled)::before {
|
||||
background-image: url('../assets/circle.svg');
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
width: 1.25em;
|
||||
height: 1em;
|
||||
background-size: 1em 1em;
|
||||
margin-top: 2px;
|
||||
filter: var(--icon-hover-filter);
|
||||
}
|
||||
|
||||
li:has(> input[type='checkbox']:checked:disabled)::before {
|
||||
background-image: url('../assets/checkmark.circle.fill.svg');
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
width: 1.25em;
|
||||
height: 1em;
|
||||
background-size: 1em 1em;
|
||||
margin-top: 1px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.image-list-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: masonry;
|
||||
|
||||
&.image-list-odd {
|
||||
:first-child {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-list-container img {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tgme_widget_message_link_preview {
|
||||
margin-top: 16px;
|
||||
display: none;
|
||||
|
||||
.link_preview_site_name,
|
||||
.link_preview_title,
|
||||
.link_preview_description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_link_preview:has(.link_preview_site_name) {
|
||||
display: block;
|
||||
background: var(--color-surface);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
border-radius: var(--radius-panel);
|
||||
|
||||
.link_preview_title {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-weight: bolder;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.link_preview_description {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_video,
|
||||
.tgme_widget_message_roundvideo {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.tgme_widget_message_link_preview:has(.link_preview_image) {
|
||||
display: flex;
|
||||
position: relative;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
.link_preview_image {
|
||||
aspect-ratio: 1200 / 630;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.link_preview_site_name {
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: var(--box-margin);
|
||||
left: var(--box-margin);
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.66);
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--radius-panel);
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - 28px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link_preview_title,
|
||||
.link_preview_description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 16px 0;
|
||||
font-size: 0.8em;
|
||||
background: var(--color-surface);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
|
||||
.tg-expandable {
|
||||
margin: 16px 0;
|
||||
font-size: 0.8em;
|
||||
background: var(--color-surface);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding: 6px;
|
||||
padding-left: 10px;
|
||||
padding-right: 30px;
|
||||
border-radius: var(--radius-panel);
|
||||
position: relative;
|
||||
min-height: 3.6em;
|
||||
}
|
||||
|
||||
.tg-expandable__checkbox {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tg-expandable__content {
|
||||
display: block;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@supports selector(:has(*)) {
|
||||
.tg-expandable__content {
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tg-expandable:has(.tg-expandable__checkbox:checked) .tg-expandable__content {
|
||||
display: block;
|
||||
line-clamp: unset;
|
||||
-webkit-line-clamp: unset;
|
||||
-webkit-box-orient: unset;
|
||||
overflow: visible;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.tg-expandable__toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports selector(:has(*)) {
|
||||
.tg-expandable__toggle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tg-expandable__toggle::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid var(--color-muted);
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform 0.2s ease;
|
||||
filter: var(--icon-secondary-filter);
|
||||
}
|
||||
|
||||
.tg-expandable:has(.tg-expandable__checkbox:checked) .tg-expandable__toggle::after {
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
|
||||
.tg-expandable:has(.tg-expandable__checkbox:focus-visible) .tg-expandable__toggle {
|
||||
outline: 2px solid var(--color-heading);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_sticker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:has(.tgme_widget_message_user_photo) {
|
||||
display: flex;
|
||||
|
||||
.tgme_widget_message_user_photo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_voice {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.tgme_widget_message_video_wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tgme_widget_message_poll_options {
|
||||
display: block;
|
||||
|
||||
.tgme_widget_message_poll_option_percent {
|
||||
float: left;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tgme_widget_message_location_wrap {
|
||||
display: block;
|
||||
|
||||
.tgme_widget_message_location {
|
||||
padding-top: 50%;
|
||||
background: no-repeat center;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-style: normal;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.tg-emoji {
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.sticker {
|
||||
width: auto;
|
||||
max-width: min(256px, calc(100% - 1px));
|
||||
height: auto;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.spoiler-button {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
tg-spoiler {
|
||||
color: transparent;
|
||||
margin: auto 2px;
|
||||
border-radius: var(--radius-panel);
|
||||
background: #ccc 60% 60% / 3000px 3000px;
|
||||
background-image: repeating-conic-gradient(#999 0 0.0001%, #0000 0 0.0002%);
|
||||
}
|
||||
|
||||
input:checked + tg-spoiler {
|
||||
background: unset;
|
||||
color: unset;
|
||||
}
|
||||
|
||||
&:has(input:focus-visible) {
|
||||
outline: 2px solid var(--color-heading);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
[popover] {
|
||||
display: none;
|
||||
|
||||
&:popover-open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-preview-wrap img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.image-preview-button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-preview-button:focus-visible {
|
||||
outline: 2px solid var(--color-heading);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
padding: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
overscroll-behavior: contain;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal:popover-open {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
z-index: 0;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal__surface {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
max-width: calc(100vw - 40px);
|
||||
max-height: calc(100dvh - 40px);
|
||||
width: auto;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 48px 24px 24px;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
position: absolute;
|
||||
top: max(16px, env(safe-area-inset-top));
|
||||
right: max(16px, env(safe-area-inset-right));
|
||||
z-index: 20;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.modal__close:hover {
|
||||
background: rgba(0, 0, 0, 0.86);
|
||||
}
|
||||
|
||||
.modal-img {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
border-radius: var(--radius-media);
|
||||
border: 1px solid var(--color-line);
|
||||
box-shadow: var(--shadow-soft);
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface Reaction {
|
||||
emoji: string
|
||||
emojiId?: string
|
||||
emojiImage?: string
|
||||
count: string
|
||||
isPaid: boolean
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
type: 'text' | 'service'
|
||||
datetime: string
|
||||
tags: string[]
|
||||
text: string
|
||||
description?: string
|
||||
content: string
|
||||
reactions: Reaction[]
|
||||
}
|
||||
|
||||
export interface ChannelInfo {
|
||||
posts: Post[]
|
||||
title: string
|
||||
description: string
|
||||
descriptionHTML: string | null
|
||||
avatar: string | undefined
|
||||
/** Optional SEO override injected by page routes */
|
||||
seo?: SeoMeta
|
||||
}
|
||||
|
||||
export interface SeoMeta {
|
||||
title?: string
|
||||
text?: string
|
||||
noindex?: string | boolean
|
||||
nofollow?: string | boolean
|
||||
}
|
||||
|
||||
/** Parameters accepted by getChannelInfo */
|
||||
export interface GetChannelInfoParams {
|
||||
before?: string
|
||||
after?: string
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface EnvCapableAstro {
|
||||
locals?: App.Locals & {
|
||||
runtime?: {
|
||||
env?: Record<string, string | undefined>
|
||||
}
|
||||
}
|
||||
request?: Request
|
||||
url?: URL
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
}
|
||||