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
This commit is contained in:
面条
2026-03-26 21:11:11 +08:00
parent 5d1001011e
commit 6ec262d8cf
55 changed files with 5063 additions and 5527 deletions

5
.gitignore vendored
View File

@@ -26,4 +26,7 @@ pnpm-debug.log*
.vercel
.netlify
.wrangler
.edgeone
.edgeone
.agents
.claude

160
AGENTS.md
View File

@@ -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.

View File

@@ -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
View 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 })
}
}

View File

@@ -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: [

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
module.exports = {
plugins: [
require('postcss-nesting')({
edition: '2021',
noIsPseudoSelector: true,
}),
require('autoprefixer'),
require('cssnano'),
],
}

15
skills-lock.json Normal file
View 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"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&nbsp;</span>
<span class={paginationPlaceholderClass} aria-hidden="true">
&nbsp;
</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">&nbsp;</span>
<span class={paginationPlaceholderClass} aria-hidden="true">
&nbsp;
</span>
)
}
</div>
</nav>
</Layout>

55
src/env.d.ts vendored
View File

@@ -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' {}

View File

@@ -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>

View File

@@ -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
View 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
View 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)
}

View File

@@ -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
View 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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
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}"
>&times;</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
View File

@@ -0,0 +1,4 @@
export const siteTitleStyle = {
'view-transition-name': 'site-title',
'transition': '0.2s ease',
} as const

View File

@@ -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
};
})

View File

@@ -11,4 +11,4 @@ channel.seo = {
}
---
<List channel={channel} />
<List channel={channel} pageHeading={channel.title} />

View File

@@ -11,4 +11,4 @@ channel.seo = {
}
---
<List channel={channel} />
<List channel={channel} pageHeading={channel.title} />

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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),

View File

@@ -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')
},
}),
})),

View File

@@ -1,4 +1,6 @@
export async function GET() {
import type { APIRoute } from 'astro'
export const GET: APIRoute = () => {
return Response.json({
prerender: [
{

View File

@@ -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}`} />

View 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',
},
})
}

View File

@@ -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

View File

@@ -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, {

View File

@@ -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 })
}
}

View 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 })
}
}

View File

@@ -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
View 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
View 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
View 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
}