
Telegraph Publisher
- 24 installs
- 177 repo stars
- Updated May 10, 2026
- artwist-polyakov/polyakov-claude-skills
telegraph-publisher is a Claude skill that publishes pages to Telegraph via API with image, YouTube, and diagram support and auto-splits long articles.
About
This skill publishes content to Telegraph through its API, converting HTML fragments into Telegraph Node JSON. A developer uses it to publish articles, research reports, and illustrated content with images, YouTube embeds, and diagrams. It manages Telegraph accounts, auto-splits long articles, and hosts permanent media through a separate GitHub repo served over jsDelivr.
- Publishes pages to Telegraph via API with images, YouTube embeds, and diagrams
- Converts HTML fragments to Telegraph Node JSON and auto-splits articles over 60KB
- Uses a separate GitHub repo plus jsDelivr CDN for permanent media hosting
Telegraph Publisher by the numbers
- 24 all-time installs (skills.sh)
- Ranked #1,254 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
telegraph-publisher capabilities & compatibility
Telegraph API is free but requires an access token; permanent media hosting uses a GitHub repo and jsDelivr.
- Capabilities
- copywriting · web design
- Works with
- github
- Use cases
- copywriting
- Pricing
- Bring your own API key
What telegraph-publisher says it does
Publish pages to Telegraph with images, YouTube embeds, and diagrams. Supports auto-split for long articles.
Best for: articles, research reports, documentation, illustrated content.
npx skills add https://github.com/artwist-polyakov/polyakov-claude-skills --skill telegraph-publisherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 177 |
| Last updated | May 10, 2026 |
| Repository | artwist-polyakov/polyakov-claude-skills ↗ |
What it does
Publish an article or report to Telegraph with images, YouTube embeds, and diagrams, auto-splitting if it is long.
Who is it for?
Publishing articles, research reports, and illustrated content to Telegraph.
When should I use this skill?
You need to publish an article, report, or illustrated content to Telegraph, including creating or managing the Telegraph account.
What you get
Content is published to Telegraph with permanent media and long articles are split across linked pages.
- Published Telegraph page URL
By the numbers
- auto-splits content over 60KB into linked pages
- supports 25 whitelisted Telegraph HTML tags
Files
telegraph-publisher
Publish content to Telegraph via API with media support. Best for: articles, research reports, documentation, illustrated content.
STOP — Read Before Acting
- DO NOT pass raw markdown — convert to HTML fragment first (Telegraph API accepts Node JSON, the converter accepts HTML)
- DO NOT pass content larger than 64KB without using auto-split — the script handles this automatically
- DO NOT hardcode access tokens — use
config/.env - DO NOT skip account setup — run
create_account.shfirst if no token exists
Quick Start
No token? → sh scripts/create_account.sh --name "Name"
Have token? → Save to config/.env
Publish page? → sh scripts/create_page.sh --title "Title" --html "<p>Content</p>"
Edit page? → sh scripts/edit_page.sh --path "Path-03-09" --title "Title" --html "<p>New</p>"
List pages? → sh scripts/list_pages.sh
Account info? → sh scripts/account_info.sh
Permanent media? → sh scripts/github_upload.sh --file hero.webp --page-path page-pathAccount & Ownership
Telegraph accounts are API-only (no password/email). Key concepts:
1. create_account.sh generates access_token + one-time auth_url 2. Open auth_url in browser to bind API account to browser session 3. Pages belong to the account whose token was used in createPage 4. After browser binding: pages visible at telegra.ph, editable both via browser and API 5. Use --revoke to rotate token if compromised
See config/README.md for full ownership model.
Compatibility
Scripts are POSIX sh compatible — work in cloud sandboxes (/bin/sh) and locally. Python scripts use stdlib only (html.parser, json, sys).
Config
Requires TELEGRAPH_ACCESS_TOKEN in config/.env or environment.
For permanent media hosting, prefer a separate public GitHub repo + jsDelivr CDN. Reason: Telegraph's unofficial upload endpoint is unstable and should not be the default publishing path.
GitHub Setup (recommended)
The agent should assume this is the default permanent media backend.
Required GitHub config:
GITHUB_TOKEN=ghp_...
GITHUB_ASSETS_REPO=owner/repo
GITHUB_ASSETS_BRANCH=main
GITHUB_ASSETS_BASE_DIR=pages
GITHUB_MANIFESTS_DIR=manifestsRecommended setup: 1. Create a separate public GitHub repo only for Telegraph media 2. Create a fine-grained PAT only for that repo 3. Grant only:
Contents:Read and write
4. Save token and repo to config/.env
Why this matters:
- permanent asset URLs via jsDelivr
- lower blast radius if token leaks
- no dependency on Telegraph's glitchy upload endpoint
- deterministic cleanup through page manifests
Agent rule:
- if local images/diagrams need permanent hosting and GitHub config exists, use GitHub-backed media workflow by default
- use
upload.shonly as a legacy fallback
Content Format
Telegraph API accepts an array of Node objects. This skill converts HTML fragments to Node JSON automatically.
Supported HTML tags (Telegraph API whitelist): a, aside, b, blockquote, br, code, em, figcaption, figure, h3, h4, hr, i, iframe, img, li, ol, p, pre, s, strong, u, ul, video
Only href and src attributes are preserved. Unsupported tags are stripped (children kept).
Special case:
- input HTML tables (
table,thead,tr,th,td) are converted into a monospacepreblock - use this for compact comparisons, domain spend breakdowns, KPI matrices, and similar tabular fragments
- do not force small tables into diagrams unless the user explicitly wants a visual chart instead of exact values
See references/CONTENT_FORMAT.md for Node format details.
Scripts
create_account.sh
sh scripts/create_account.sh --name "Author Name" [--author-url "https://..."]
sh scripts/create_account.sh --revoke # rotate tokenaccount_info.sh
sh scripts/account_info.sh
sh scripts/account_info.sh --with-auth-url # include auth_url in outputcreate_page.sh
# From HTML string
sh scripts/create_page.sh --title "Article" --html "<h3>Hello</h3><p>World</p>"
# From HTML file
sh scripts/create_page.sh --title "Article" --html-file article.html
# From pre-built Node JSON
sh scripts/create_page.sh --title "Article" --content-file nodes.json
# With author info
sh scripts/create_page.sh --title "Article" --html-file a.html --author-name "Name"| Param | Required | Description |
|---|---|---|
--title | yes | Page title (1-256 chars) |
--html | one of three | Inline HTML string |
--html-file | one of three | Path to HTML file |
--content-file | one of three | Path to Node JSON file |
--author-name | no | Author name (0-128 chars) |
--author-url | no | Author profile URL |
Auto-split: If content exceeds 60KB, automatically splits into multiple pages with an index page linking to parts.
edit_page.sh
sh scripts/edit_page.sh --path "Page-Title-03-09" --title "Updated Title" --html "<p>New content</p>"| Param | Required | Description |
|---|---|---|
--path | yes | Page path (from URL or create output) |
--title | yes | Page title |
--html / --html-file / --content-file | yes | New content |
--author-name | no | Author name |
--author-url | no | Author URL |
list_pages.sh
sh scripts/list_pages.sh
sh scripts/list_pages.sh --offset 0 --limit 20github_upload.sh
Upload local media to the GitHub assets repo and update page manifest:
sh scripts/github_upload.sh --file ./hero.webp --page-path my-page-path
sh scripts/github_upload.sh --file ./diagram.png --page-path my-page-path --name diagram-01.png| Param | Required | Description |
|---|---|---|
--file | yes | Local asset file |
--page-path | yes | Telegraph page path used as manifest/asset key |
--name | no | Override stored filename in GitHub |
Output: commit-pinned jsDelivr URL.
Manifest behavior:
- assets go under
pages/<telegraph_path>/... - manifest goes under
manifests/<telegraph_path>.json - manifest stores asset paths and SHAs for later cleanup
github_delete_page_assets.sh
Delete all GitHub-backed assets for a page using its manifest:
sh scripts/github_delete_page_assets.sh --page-path my-page-path| Param | Required | Description |
|---|---|---|
--page-path | yes | Telegraph page path |
Cleanup rule:
- delete by manifest, not by title guessing
- use Telegraph
pathas the stable page identifier
upload.sh
Legacy fallback for local image/video upload to Telegraph:
# Best-effort only
URL=$(sh scripts/upload.sh --file /path/to/photo.jpg)
# Use in HTML
echo "<figure><img src=\"$URL\"><figcaption>My photo</figcaption></figure>"| Param | Required | Description |
|---|---|---|
--file | yes | Path to image/video (jpg, png, gif, webp, mp4; max 5MB) |
--insecure | no | Skip SSL verification (for HTTPS-intercepting proxies/VPNs) |
Note: Uses unofficial telegra.ph/upload endpoint. Do not treat it as the primary workflow. Best-effort only — may fail behind corporate proxies/VPNs or without any obvious reason.
render_diagram.sh
Render PlantUML/Mermaid diagrams via public servers:
# Get render URL (image on public server)
sh scripts/render_diagram.sh --type plantuml --file arch.puml
# Render + upload to GitHub-backed permanent media
sh scripts/render_diagram.sh --type mermaid --file flow.mmd --github-page-path my-page-path --github-name cohort.png
# Legacy fallback: render + upload via Telegraph upload
sh scripts/render_diagram.sh --type mermaid --file flow.mmd --upload| Param | Required | Description |
|---|---|---|
--type | yes | plantuml or mermaid |
--file | yes | Path to diagram source file |
--github-page-path | no | Upload rendered file to GitHub assets under this Telegraph path |
--github-name | no | Override GitHub filename for rendered asset |
--upload | no | Legacy fallback: download rendered PNG and upload to Telegraph |
Privacy: Diagram source is sent to plantuml.com / mermaid.ink. Do not use for confidential content.
content_converter.py (internal)
# HTML → Node JSON
echo '<p>Hello <b>world</b></p>' | python3 scripts/content_converter.py
# Check serialized size (bytes)
cat nodes.json | python3 scripts/content_converter.py --check-size
# Split large content
cat nodes.json | python3 scripts/content_converter.py --split --output-dir /tmp/partsMedia Support
Images
Preferred workflow: upload local files to a dedicated public GitHub assets repo and serve them via jsDelivr.
Why GitHub is worth connecting:
- stable permanent URLs for Telegraph pages
- no dependency on Telegraph's glitchy unofficial upload endpoint
- predictable asset structure for cleanup
- easy separation between article content and media storage
Fallback workflow: use upload.sh only when GitHub-backed hosting is unavailable.
Recommended asset lifecycle: 1. If a page contains local media, first create a draft/stub Telegraph page to get its final path 2. Upload images/diagrams to GitHub under pages/<telegraph_path>/... via github_upload.sh 3. Publish final content with jsDelivr URLs 4. Store a manifest for that page with uploaded asset paths and GitHub blob SHAs 5. On page cleanup/removal, run github_delete_page_assets.sh --page-path <telegraph_path>
Agent decision rule:
- if the user wants permanent images, diagrams, or hero art, prefer GitHub-backed media
- if the page is temporary and the user explicitly accepts risk,
upload.shcan be used as fallback - if the page is being deleted or rebuilt, clean up assets through
github_delete_page_assets.sh
Do not key cleanup only by page title. Titles can change. Use Telegraph path as the primary page identifier.
If a two-pass draft flow is not available, use a temporary page key and persist a manifest mapping: telegraph_path -> github asset paths.
Legacy fallback: upload local files or use public URLs:
# Local file → Telegraph URL
URL=$(sh scripts/upload.sh --file photo.jpg)Then embed in HTML:
<figure>
<img src="https://telegra.ph/file/abc123.jpg">
<figcaption>Photo caption</figcaption>
</figure>See references/IMAGE_WORKFLOWS.md for workflows.
YouTube Embeds
YouTube URLs are automatically normalized to embed format:
<figure>
<iframe src="https://www.youtube.com/watch?v=VIDEO_ID"></iframe>
</figure>The converter transforms watch?v= and youtu.be/ URLs to /embed/ format.
See references/YOUTUBE_EMBEDS.md for details.
Diagrams
Preferred workflow: render PlantUML/Mermaid, store the image in GitHub assets, then publish jsDelivr URL.
Legacy fallback: render PlantUML/Mermaid to image and upload:
# Preferred: render + GitHub upload
URL=$(sh scripts/render_diagram.sh --type plantuml --file arch.puml --github-page-path my-page-path --github-name arch.png)
# Legacy fallback
URL=$(sh scripts/render_diagram.sh --type plantuml --file arch.puml --upload)See references/DIAGRAMS.md for details and privacy considerations.
Tables
Telegraph does not support real HTML tables as native nodes. This skill handles that by converting input HTML tables into a readable monospace pre block.
Use tables when:
- exact values matter more than visual storytelling
- the user needs a compact spend/domain breakdown
- the content should stay copyable and stable in Telegraph
Use diagrams when:
- you need trends, shares, flow, cohorts, or process explanation
- the user benefits from visual comparison more than exact cell-by-cell reading
Mobile-first rule:
- do not use
pretables for wide tables with 3+ dense columns or long labels - on mobile, wide monospace tables wrap badly and become unreadable
- for mobile-sensitive reports, prefer one of these:
- bar/pie/cohort diagram plus a short numeric summary
- bullet list or mini-cards: one metric/domain per row
- a narrow 2-column table only if the content still fits comfortably
Example input:
<table>
<thead>
<tr><th>Домен</th><th>Расход, руб.</th></tr>
</thead>
<tbody>
<tr><td>metallik.ru</td><td>82 900</td></tr>
<tr><td>mir-shtaketnika.ru</td><td>38 367</td></tr>
</tbody>
</table>This will be published as a boxed monospace table inside a pre block.
Optional: Illustrations with fal-ai-image
If the fal-ai-image skill is installed, you can generate illustrations before publishing:
1. Read fal-ai-image SKILL.md first 2. Confirm budget with user before generating (from $0.15/image) 3. Generate images, save URLs 4. Include URLs in HTML as <figure><img src="URL"></figure> 5. Publish via create_page.sh
See references/FAL_AI_INTEGRATION.md for house style guide and prompt examples.
Important: telegraph-publisher works fully without fal-ai-image. This is an optional enhancement.
Limitations (v1)
- Input: HTML fragments only (no markdown conversion)
- No caching of API responses
- Auto-split boundary: if a single HTML element exceeds 60KB, manual splitting required
upload.shuses unofficial Telegraph endpoint and should be treated as legacy fallback only- Diagram rendering sends source to public servers (privacy consideration)
config/.env
# Telegraph API access token
# Get one by running: sh scripts/create_account.sh --name "YourName"
TELEGRAPH_ACCESS_TOKEN=your_token_here
# GitHub-backed permanent media hosting (recommended)
# Public repo in owner/repo format, for example: yourname/telegraph-assets
GITHUB_TOKEN=your_github_token_here
GITHUB_ASSETS_REPO=owner/repo
GITHUB_ASSETS_BRANCH=main
GITHUB_ASSETS_BASE_DIR=pages
GITHUB_MANIFESTS_DIR=manifests
Telegraph Publisher — Configuration
Quick Start
1. Create a Telegraph account:
sh scripts/create_account.sh --name "Your Name"This outputs: access_token, auth_url, short_name.
2. Copy the token to config:
cp config/.env.example config/.env
# Edit config/.env and paste your access_token3. (Optional) Open auth_url in your browser to bind the account to your browser session.
Access Token
The TELEGRAPH_ACCESS_TOKEN is required for all operations except reading public pages.
You can set it in two ways:
- File:
config/.env(recommended) - Environment variable:
export TELEGRAPH_ACCESS_TOKEN=...
GitHub Media Hosting (recommended)
For permanent images and diagrams, configure a separate public GitHub repo and serve assets through jsDelivr.
Recommended architecture:
- create a separate public repo only for Telegraph media
- do not reuse your main code repo for images
- create a separate fine-grained PAT that has access only to that media repo
Required variables:
GITHUB_TOKEN=ghp_...
GITHUB_ASSETS_REPO=owner/repo
GITHUB_ASSETS_BRANCH=main
GITHUB_ASSETS_BASE_DIR=pages
GITHUB_MANIFESTS_DIR=manifestsWhy GitHub is recommended
- Telegraph's upload endpoint is unofficial and unstable
- jsDelivr gives permanent CDN URLs for published pages
- assets can be grouped per Telegraph page
- cleanup becomes deterministic via manifest files
Minimal setup
1. Create a public GitHub repo for Telegraph assets Example: yourname/telegraph-assets 2. Open GitHub -> Settings -> Developer settings -> Personal access tokens -> Fine-grained tokens 3. Click Generate new token 4. In Resource owner, choose the user or organization that owns the assets repo 5. In Repository access, choose Only select repositories 6. Select only that one assets repo 7. In permissions, set:
Contents:Read and write
8. Create the token and save it immediately 9. Save repo and token to config/.env 10. Upload media through github_upload.sh
Recommended token type:
- fine-grained PAT
- repo scope limited to the assets repo
- permission:
Contents=Read and write
Why a separate repo + separate token
- if the token leaks, the blast radius is limited to media files only
- no access to your main code repositories
- cleanup scripts can freely create/update/delete manifests and assets without touching application code
- the repo stays easy to inspect: only page assets and manifests live there
Suggested repo contents
The media repo should contain only:
pages/<telegraph_path>/...assetsmanifests/<telegraph_path>.jsonmanifests
Avoid storing anything else there.
Manifest-driven cleanup
Each Telegraph page should have a manifest:
manifests/<telegraph_path>.jsonThe manifest stores uploaded asset paths and SHAs. Later cleanup should delete assets by manifest, not by title guessing.
Recommended lifecycle: 1. create or obtain final Telegraph path 2. upload assets under pages/<telegraph_path>/... 3. publish page with jsDelivr URLs 4. when page is removed, run github_delete_page_assets.sh --page-path <telegraph_path>
Using an Existing Telegraph Account
If you already have a Telegraph account in the browser, the simplest way is to extract the token from cookies (see above).
Alternatively, create a new API account: 1. sh scripts/create_account.sh --name "Your Name" 2. Save the token to config/.env 3. Open auth_url in the browser to log into this new account Warning: This replaces your current browser session, not merges with it.
Extracting token from browser
If you already have a Telegraph account in the browser, you can extract the API token:
1. Open any of your Telegraph pages in Chrome 2. DevTools (F12) → Application → Cookies → https://telegra.ph 3. Find cookie tph_token — its value IS your access_token
Note: This cookie is httpOnly, so document.cookie won't show it. You must use the Application tab in DevTools. Safari may not display httpOnly cookies in its inspector.
Account Ownership Model
Telegraph has a specific ownership model that differs from most publishing platforms:
How it works
1. `createAccount` generates a new Telegraph account with a unique access_token. This account is API-only — it has no password, no email, no login.
2. `auth_url` is a one-time link (valid 5 minutes) that binds the API account to your browser session. After opening it:
- Pages you created via API become visible in your browser at telegra.ph
- You can edit pages both via browser and via API
- Your browser Telegraph history merges with the API account
3. Page ownership: A page belongs to the account whose access_token was used in createPage. Only that account can edit the page via API (can_edit: true).
4. If you already use Telegraph in browser: Opening auth_url will link your existing browser pages to the API account. Use revokeAccessToken (via create_account.sh --revoke) to get a fresh auth_url if the previous one expired.
Viewing your pages
- Via API:
sh scripts/list_pages.sh— shows all pages owned by the account - Via browser: Open
auth_urlfirst, then visit telegra.ph — your pages appear in the sidebar
Security
- Anyone with your
access_tokencan create/edit pages under your account - Use
create_account.sh --revoketo rotate the token if compromised (old token becomes invalid) - Store
config/.envsecurely, never commit it to git
Telegraph Content Format Reference
Node Format
Telegraph API content is a JSON array of Node objects.
A Node is either:
- A string (text content)
- A NodeElement object:
{
"tag": "p",
"attrs": {"href": "https://...", "src": "https://..."},
"children": ["text", {"tag": "b", "children": ["bold"]}]
}Fields
| Field | Type | Required | Description |
|---|---|---|---|
tag | string | yes | HTML tag name |
attrs | object | no | Only href and src allowed |
children | array | no | Child Nodes (strings or NodeElements) |
Supported Tags
| Tag | Attrs | Description |
|---|---|---|
a | href | Hyperlink |
aside | — | Aside/callout block |
b | — | Bold |
blockquote | — | Block quote |
br | — | Line break (void element) |
code | — | Inline code |
em | — | Emphasis/italic |
figcaption | — | Figure caption |
figure | — | Container for img/iframe/video |
h3 | — | Heading level 3 |
h4 | — | Heading level 4 |
hr | — | Horizontal rule (void element) |
i | — | Italic |
iframe | src | Embedded content (YouTube, etc.) |
img | src | Image (void element) |
li | — | List item |
ol | — | Ordered list |
p | — | Paragraph |
pre | — | Preformatted/code block |
s | — | Strikethrough |
strong | — | Strong emphasis |
u | — | Underline |
ul | — | Unordered list |
video | src | Video |
Size Limit
- Maximum content size: 64 KB (serialized UTF-8 JSON)
- The
content_converter.pyuses 60 KB threshold for auto-split (4 KB safety margin)
Examples
Simple paragraph
[{"tag": "p", "children": ["Hello, world!"]}]Formatted text
[{"tag": "p", "children": ["This is ", {"tag": "b", "children": ["bold"]}, " and ", {"tag": "i", "children": ["italic"]}]}]Image with caption
[{"tag": "figure", "children": [
{"tag": "img", "attrs": {"src": "https://example.com/photo.jpg"}},
{"tag": "figcaption", "children": ["Photo description"]}
]}]YouTube embed
[{"tag": "figure", "children": [
{"tag": "iframe", "attrs": {"src": "https://www.youtube.com/embed/dQw4w9WgXcQ"}}
]}]Code block
[{"tag": "pre", "children": [{"tag": "code", "children": ["def hello():\n print('world')"]}]}]Complete article
[
{"tag": "h3", "children": ["Introduction"]},
{"tag": "p", "children": ["This article covers..."]},
{"tag": "figure", "children": [
{"tag": "img", "attrs": {"src": "https://example.com/diagram.png"}},
{"tag": "figcaption", "children": ["Architecture diagram"]}
]},
{"tag": "h3", "children": ["Details"]},
{"tag": "p", "children": ["The implementation uses ", {"tag": "code", "children": ["async/await"]}, " pattern."]}
]HTML to Node Mapping
The content_converter.py script converts HTML fragments to Node JSON:
<h3>Title</h3> → {"tag": "h3", "children": ["Title"]}
<p>Text <b>bold</b></p> → {"tag": "p", "children": ["Text ", {"tag": "b", "children": ["bold"]}]}
<img src="url"> → {"tag": "img", "attrs": {"src": "url"}}
<a href="url">link</a> → {"tag": "a", "attrs": {"href": "url"}, "children": ["link"]}Unsupported tags (e.g., <div>, <span>, <h1>, <h2>) are stripped — their children are preserved and moved to the parent element.
Table Handling
Telegraph does not support native table nodes.
This skill accepts input HTML tables and converts them to a monospace pre block:
<table>
<thead>
<tr><th>Домен</th><th>Расход, руб.</th></tr>
</thead>
<tbody>
<tr><td>metallik.ru</td><td>82 900</td></tr>
<tr><td>mir-shtaketnika.ru</td><td>38 367</td></tr>
</tbody>
</table>becomes a pre node with aligned box-drawing output, for example:
[{
"tag": "pre",
"children": ["┌────────────────────┬──────────────┐\n│ Домен │ Расход, руб. │\n├────────────────────┼──────────────┤\n│ metallik.ru │ 82 900 │\n│ mir-shtaketnika.ru │ 38 367 │\n└────────────────────┴──────────────┘"]
}]Recommended usage:
- exact numbers and labels
- short comparison tables
- spend/domain breakdowns
Not recommended:
- wide tables with many columns
- dense financial statements
- anything that should become a chart instead of a table
Mobile note:
pretables are desktop-friendly but degrade quickly on narrow mobile screens- if the table has many columns, long labels, or status text, prefer:
- a diagram for the visual pattern
- a bullet/card layout for exact values
- or a narrowed 2-column table plus a short textual summary
Platform Limitations
No page deletion
Telegraph does not support deleting published pages — neither via API nor via browser. Once published, a page exists permanently at its URL.
Workaround: page recycling. You can overwrite a page's title and content via editPage:
# "Delete" by clearing content
sh scripts/edit_page.sh --path "Old-Page-03-09" --title "." --html "<p>.</p>"
# Later, reuse for new content
sh scripts/edit_page.sh --path "Old-Page-03-09" --title "New Article" --html-file article.htmlThis is useful for:
- Cleaning up test/draft pages
- Reusing page URLs for updated content
- "Unpublishing" by replacing with minimal content
Note: The original URL slug (e.g., Old-Page-03-09) cannot be changed. Only title and content are editable.
No page URL customization
Page URL paths are auto-generated from the title at creation time. They cannot be changed later, even if you edit the title.
Account token in browser cookies
The API access_token is stored as an httpOnly cookie named tph_token on telegra.ph. This is the same token used by createAccount API. You can extract it from Chrome DevTools → Application → Cookies (see config/README.md).
Diagrams in Telegraph
Render PlantUML/Mermaid diagrams to images and publish them.
Preferred publishing target for rendered diagrams: GitHub assets repo + jsDelivr. Do not rely on Telegraph upload as the default destination.
Quick Start
# PlantUML: render + GitHub upload in one step
URL=$(sh scripts/render_diagram.sh --type plantuml --file arch.puml --github-page-path my-page-path --github-name arch.png)
# Mermaid: same workflow
URL=$(sh scripts/render_diagram.sh --type mermaid --file flow.mmd --github-page-path my-page-path --github-name flow.png)
# Then use URL in your HTML:
# <figure><img src="$URL"><figcaption>Architecture</figcaption></figure>Two Modes
URL only (no upload)
Returns a render URL pointing to the public server. Image is rendered on-demand each time.
sh scripts/render_diagram.sh --type plantuml --file diagram.puml
# → https://www.plantuml.com/plantuml/png/ENCODEDWith GitHub upload (recommended)
Downloads the rendered PNG, stores it in the GitHub assets repo, and returns a commit-pinned jsDelivr URL.
sh scripts/render_diagram.sh --type mermaid --file flow.mmd --github-page-path my-page-path --github-name flow.pngWith upload (legacy fallback)
Downloads the rendered PNG and tries to upload it through Telegraph's unofficial upload endpoint.
sh scripts/render_diagram.sh --type mermaid --file flow.mmd --upload
# → https://telegra.ph/file/abc123.pngDo not treat this as the recommended publishing path. Preferred path for articles: 1. render diagram 2. store PNG in GitHub assets repo 3. use jsDelivr URL in Telegraph
Supported Diagram Types
PlantUML
@startuml
Alice -> Bob: Authentication Request
Bob --> Alice: Authentication Response
@endumlServer: plantuml.com
Mermaid
graph LR
A[Client] --> B[API Gateway]
B --> C[Auth Service]
B --> D[Data Service]Server: mermaid.ink
Privacy Warning
Diagram source code is sent to external public servers when using render_diagram.sh.
- PlantUML diagrams go to
plantuml.com - Mermaid diagrams go to
mermaid.ink
Do not use for confidential content. If your diagram contains sensitive information: 1. Render locally (requires Java for PlantUML or Node.js for Mermaid) 2. Upload the resulting PNG to your GitHub assets repo 3. Use jsDelivr URL in Telegraph
PlantUML Caveats
PlantUML's public server does not return HTTP errors for invalid diagrams. Instead, it returns a PNG image containing an error message. The script warns about this:
"Verify the rendered image visually before publishing."
Readability Checklist
Before publishing a diagram:
- Text is readable at Telegraph page width (~700px)
- Colors have sufficient contrast
- Diagram is not overly dense (split complex ones into multiple images)
- Caption describes what the diagram shows
- Use PNG format (Telegraph may not render SVG)
Local Rendering (alternative)
If you don't want to send diagram source to external servers:
PlantUML (requires Java):
plantuml -tpng diagram.puml
# Then store diagram.png in GitHub assets repo and use jsDelivr URLMermaid CLI (requires Node.js):
npx @mermaid-js/mermaid-cli -i diagram.mmd -o diagram.png
# Then store diagram.png in GitHub assets repo and use jsDelivr URLCleanup Strategy
Rendered diagrams should live under the page-specific GitHub folder: pages/<telegraph_path>/diagram-01.png
Track them in the page manifest: manifests/<telegraph_path>.json
When a page is cleaned up: 1. read manifest by Telegraph path 2. delete diagram files by recorded GitHub path + SHA 3. remove the manifest
This is more reliable than deriving assets from title text.
fal-ai-image Integration (Optional)
Overview
If the fal-ai-image skill is installed, it can generate illustrations for Telegraph articles.
This is optional. The telegraph-publisher skill works fully without fal-ai-image.
Prerequisites
1. fal-ai-image skill installed and configured (FAL_KEY in its config) 2. Read fal-ai-image SKILL.md before use 3. User must confirm budget before any generation ($0.15+/image)
Workflow
1. Draft article in HTML 2. Identify illustration needs (hero image, section illustrations, diagrams) 3. Confirm budget with user: "N images × $0.15 = $X.XX. Proceed?" 4. Generate images via fal-ai-image scripts 5. Save image URLs (they expire in ~1 hour — download locally if needed) 6. Insert URLs into HTML as <figure><img src="URL"></figure> 7. Publish via create_page.sh
House Style for Editorial Illustrations
When generating illustrations for articles, use this minimalist style:
Style Guide
- Feel: editorial, infographic
- Background: light, clean (white or soft neutral)
- Composition: clean, minimal visual noise
- Priority: readability over decoration
- Colors: limited palette, high contrast for text/diagrams
Prompt Template
[Subject description]. Editorial illustration style, clean white background,
minimalist infographic aesthetic, limited color palette, high contrast,
professional look, no visual clutter.Example Prompts
Hero image for tech article:
Abstract visualization of data flowing through neural network layers.
Editorial illustration style, clean white background, minimalist
infographic aesthetic, blue and teal color palette, high contrast,
professional look, no visual clutter.Section illustration:
Simple diagram showing client-server architecture with labeled components.
Editorial illustration style, clean white background, minimalist flat design,
limited color palette, high contrast text labels, no decorative elements.Concept illustration:
Metaphorical visualization of API as a bridge connecting two platforms.
Editorial illustration style, light background, clean geometric shapes,
minimal color palette, professional infographic feel.Important Notes
- fal-ai-image URLs expire in ~1 hour. For persistent articles, download images and re-upload to permanent hosting before publishing
- Always confirm pricing with user before generation
- Do not generate images for every section — use sparingly for maximum impact
- The model renders text natively (including Cyrillic) — useful for labeled diagrams
Image Workflows for Telegraph
Primary Workflow: GitHub Assets + jsDelivr
Preferred setup for permanent media in Telegraph:
- store assets in a dedicated public GitHub repo
- publish them through jsDelivr
- use those URLs in Telegraph HTML
Security recommendation:
- use a separate repo only for pictures/assets
- use a separate fine-grained token that can write only to that repo
Why this is the default:
- URLs are stable and do not depend on Telegraph's unofficial upload endpoint
- the workflow is reproducible in shell scripts
- assets can be grouped per page and deleted later
- the repo doubles as an auditable media store
Recommended structure
Use one public repo only for Telegraph media, for example: your-org/telegraph-assets
Do not mix this with your main application repo unless you intentionally want media history there.
Recommended layout:
pages/
<telegraph_path>/
hero.webp
diagram-01.png
diagram-02.png
manifests/
<telegraph_path>.jsonServe files through jsDelivr using commit-pinned URLs:
https://cdn.jsdelivr.net/gh/<owner>/<repo>@<commit>/pages/<telegraph_path>/hero.webpUse commit-pinned URLs, not branch URLs, for published pages.
Minimal commands
Upload one asset:
sh scripts/github_upload.sh \
--file ./images/hero.webp \
--page-path my-telegraph-page-pathUpload with explicit filename:
sh scripts/github_upload.sh \
--file ./images/diagram.png \
--page-path my-telegraph-page-path \
--name diagram-01.pngCleanup all assets for a page:
sh scripts/github_delete_page_assets.sh \
--page-path my-telegraph-page-pathWhy connect GitHub at all
Without GitHub-backed hosting, the fallback is upload.sh, which relies on Telegraph's unofficial upload endpoint and behaves unreliably in real environments.
In practice, GitHub gives the skill something Telegraph itself does not:
- permanent asset hosting
- predictable cleanup
- asset grouping by page
- better control over revisions
Asset Lifecycle
Recommended: create page path first
Do not derive cleanup only from the page title. Titles can change. The stable identifier is the Telegraph path.
Recommended two-pass publishing flow: 1. Create a stub Telegraph page to obtain its final path 2. Upload assets to GitHub under pages/<telegraph_path>/... 3. Build final HTML with jsDelivr URLs 4. Edit the page with final content 5. Write manifests/<telegraph_path>.json with:
- GitHub asset paths
- blob SHAs
- optional metadata (title, created_at, source files)
This makes later cleanup deterministic.
If stub-first is inconvenient
Fallback flow: 1. Generate a temporary page key 2. Upload assets under that key 3. Create the Telegraph page 4. Persist a manifest mapping: telegraph_path -> github asset paths
This is acceptable, but path-first is cleaner.
Cascade Delete Strategy
Telegraph does not give you a clean hard-delete flow you should rely on, so cleanup should be driven by your own manifest.
When a page is removed, tombstoned, or explicitly cleaned up: 1. Take the Telegraph path 2. Read manifests/<telegraph_path>.json 3. Delete listed GitHub files using stored blob SHAs 4. Delete the manifest itself 5. Optionally remove the empty pages/<telegraph_path>/ directory marker if you keep one
Do not try to infer all assets from the page title alone. Use the manifest as the source of truth.
Alternative: Already-hosted Images
If images are already on a public URL, use directly:
<figure>
<img src="https://cdn.example.com/photo.jpg">
<figcaption>Caption</figcaption>
</figure>Legacy Fallback: upload.sh
If GitHub-backed hosting is not available, upload.sh can still be tried as a last resort:
URL=$(sh scripts/upload.sh --file /path/to/photo.jpg)Treat it as emergency fallback only.
Supported formats
- Images: jpg, jpeg, png, gif, webp
- Video: mp4
- Max file size: 5MB
Limitations
- Unofficial endpoint
- No API guarantees
- May fail behind HTTPS-intercepting proxies/VPNs
- Can fail even when the rest of Telegraph API works
Working with fal-ai-image
If using the fal-ai-image skill to generate illustrations:
1. Generated images come with temporary URLs (~1 hour expiry) 2. Download the image locally first 3. Upload to GitHub assets repo for a permanent URL 4. Use the jsDelivr URL in your article
# After fal-ai-image generates image and saves to local file:
URL="https://cdn.jsdelivr.net/gh/<owner>/<repo>@<commit>/pages/<telegraph_path>/generated_hero.png"
# Use $URL in your HTML contentBest Practices
- Use HTTPS URLs (Telegraph serves over HTTPS, mixed content may be blocked)
- Prefer PNG for diagrams/screenshots, JPEG for photos
- Keep filenames deterministic inside each page folder
- Keep a manifest per Telegraph page
- Always wrap images in
<figure>for proper Telegraph rendering - Add
<figcaption>for accessibility and context
YouTube Embeds in Telegraph
Supported Input Formats
The content_converter.py automatically normalizes these YouTube URL formats to embed:
| Input URL | Normalized to |
|---|---|
https://www.youtube.com/watch?v=VIDEO_ID | https://www.youtube.com/embed/VIDEO_ID |
https://youtube.com/watch?v=VIDEO_ID | https://www.youtube.com/embed/VIDEO_ID |
https://youtu.be/VIDEO_ID | https://www.youtube.com/embed/VIDEO_ID |
https://www.youtube.com/embed/VIDEO_ID | (unchanged) |
How to Embed
Use <iframe> inside <figure>:
<figure>
<iframe src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"></iframe>
<figcaption>Optional video description</figcaption>
</figure>The converter automatically transforms the src to embed format:
{
"tag": "figure",
"children": [
{"tag": "iframe", "attrs": {"src": "https://www.youtube.com/embed/dQw4w9WgXcQ"}},
{"tag": "figcaption", "children": ["Optional video description"]}
]
}Notes
- Video ID is always 11 characters:
[a-zA-Z0-9_-]{11} - Only YouTube is auto-normalized. Other iframe sources pass through unchanged.
- Telegraph renders iframes with a fixed aspect ratio
- URL parameters (like
?t=120for timestamps) on watch URLs are stripped during normalization. If you need a start time, use the embed parameter format:https://www.youtube.com/embed/VIDEO_ID?start=120
Other Embeddable Services
Telegraph's <iframe> tag supports any embeddable URL. The converter passes non-YouTube iframe sources unchanged:
<figure>
<iframe src="https://www.example.com/embed/content"></iframe>
</figure>Use this for Vimeo, Twitter embeds, or other services that provide embed URLs.
#!/bin/sh
# Get Telegraph account info
# Usage: sh account_info.sh [--fields short_name,author_name,author_url,auth_url,page_count]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
# Parse arguments
FIELDS="short_name,author_name,author_url,page_count"
while [ $# -gt 0 ]; do
case "$1" in
--fields) FIELDS="$2"; shift 2 ;;
--with-auth-url) FIELDS="short_name,author_name,author_url,auth_url,page_count"; shift ;;
*) shift ;;
esac
done
# Convert comma-separated fields to JSON array format for API
# Telegraph expects: fields=["short_name","author_name"]
_fields_json="["
_first=1
_ifs_save="$IFS"
IFS=","
for _f in $FIELDS; do
if [ "$_first" = "1" ]; then
_fields_json="${_fields_json}\"$_f\""
_first=0
else
_fields_json="${_fields_json},\"$_f\""
fi
done
IFS="$_ifs_save"
_fields_json="${_fields_json}]"
_result=$(telegraph_post "getAccountInfo" \
-d "access_token=$TELEGRAPH_ACCESS_TOKEN" \
--data-urlencode "fields=$_fields_json")
# Use parse_response.py for human-readable output
echo "$_result" | python3 "$SCRIPT_DIR/parse_response.py" account_info
#!/bin/sh
# Common functions for Telegraph Publisher skill
# POSIX sh compatible — no bashisms
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/../config/.env"
TELEGRAPH_API="https://api.telegra.ph"
GITHUB_API="https://api.github.com"
# Ensure tmp directory exists
TPH_TMPDIR="${TMPDIR:-/tmp}"
mkdir -p "$TPH_TMPDIR"
# --------------- Config ---------------
load_config() {
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
fi
if [ -z "$TELEGRAPH_ACCESS_TOKEN" ]; then
echo "Error: TELEGRAPH_ACCESS_TOKEN not found." >&2
echo "Set in config/.env or environment. See config/README.md." >&2
exit 1
fi
}
# load_config_optional — does not fail if token is missing
load_config_optional() {
if [ -f "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
fi
}
load_github_config() {
load_config_optional
if [ -z "$GITHUB_TOKEN" ]; then
echo "Error: GITHUB_TOKEN not found." >&2
echo "Set in config/.env or environment. See config/README.md." >&2
exit 1
fi
if [ -z "$GITHUB_ASSETS_REPO" ]; then
echo "Error: GITHUB_ASSETS_REPO not found." >&2
echo "Expected format: owner/repo" >&2
echo "Set in config/.env or environment. See config/README.md." >&2
exit 1
fi
GITHUB_ASSETS_BRANCH="${GITHUB_ASSETS_BRANCH:-main}"
GITHUB_ASSETS_BASE_DIR="${GITHUB_ASSETS_BASE_DIR:-pages}"
GITHUB_MANIFESTS_DIR="${GITHUB_MANIFESTS_DIR:-manifests}"
}
check_python3() {
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is required but not found." >&2
exit 1
fi
}
check_curl() {
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required but not found." >&2
exit 1
fi
}
check_prerequisites() {
check_python3
check_curl
}
make_secure_tmpdir() {
_old_umask=$(umask)
umask 077
_tmpdir=$(mktemp -d "${TPH_TMPDIR}/tph_XXXXXX")
umask "$_old_umask"
echo "$_tmpdir"
}
slugify_filename() {
python3 - "$1" <<'PY'
import os, re, sys
name = os.path.basename(sys.argv[1])
base, ext = os.path.splitext(name)
base = re.sub(r'[^A-Za-z0-9._-]+', '-', base.strip().lower()).strip('-')
if not base:
base = 'asset'
print(base + ext.lower())
PY
}
# --------------- API helpers ---------------
# telegraph_post <method> [curl_args...]
# Makes POST request to Telegraph API. Returns body.
telegraph_post() {
_tp_method="$1"
shift
_tp_url="${TELEGRAPH_API}/${_tp_method}"
_tp_tmpfile="${TPH_TMPDIR}/telegraph_response_$$.json"
trap 'rm -f "$_tp_tmpfile"' EXIT
curl -s -X POST "$@" "$_tp_url" > "$_tp_tmpfile" || {
echo "Error: curl failed for $_tp_url" >&2
return 1
}
# Check API-level error
_tp_ok=$(json_extract_bool "$_tp_tmpfile" "ok")
if [ "$_tp_ok" = "false" ]; then
_tp_error=$(json_extract_field "$_tp_tmpfile" "error")
echo "Error: Telegraph API: $_tp_error" >&2
cat "$_tp_tmpfile" >&2
return 1
fi
cat "$_tp_tmpfile"
}
# --------------- JSON helpers (flat fields only) ---------------
# json_extract_field <file> <field_name> — extracts string value
json_extract_field() {
grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1" | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//'
}
# json_extract_number <file> <field_name> — extracts numeric value
json_extract_number() {
grep -o "\"$2\"[[:space:]]*:[[:space:]]*[0-9]*" "$1" | head -1 | sed 's/.*:[[:space:]]*//'
}
# json_extract_bool <file> <field_name> — extracts true/false
json_extract_bool() {
grep -o "\"$2\"[[:space:]]*:[[:space:]]*[a-z]*" "$1" | head -1 | sed 's/.*:[[:space:]]*//'
}
#!/usr/bin/env python3
"""Convert HTML fragment to Telegraph Node JSON format.
Usage:
echo '<p>Hello <b>world</b></p>' | python3 content_converter.py
python3 content_converter.py < article.html
python3 content_converter.py --check-size # reads Node JSON from stdin, prints byte size
python3 content_converter.py --split --output-dir DIR # reads Node JSON from stdin, splits into parts
Supported Telegraph tags (output whitelist):
a, aside, b, blockquote, br, code, em, figcaption, figure,
h3, h4, hr, i, iframe, img, li, ol, p, pre, s, strong, u, ul, video
Special input handling:
table, thead, tr, th, td -> converted to a monospace preformatted table
Unsupported tags are stripped (children preserved).
Only href and src attributes are kept.
"""
import json
import re
import sys
import os
from html.parser import HTMLParser
# Tags that Telegraph API accepts
ALLOWED_TAGS = frozenset([
'a', 'aside', 'b', 'blockquote', 'br', 'code', 'em', 'figcaption',
'figure', 'h3', 'h4', 'hr', 'i', 'iframe', 'img', 'li', 'ol',
'p', 'pre', 's', 'strong', 'u', 'ul', 'video'
])
# Only these attributes are allowed by Telegraph
ALLOWED_ATTRS = frozenset(['href', 'src'])
# Tags that are block-level (for splitting)
BLOCK_TAGS = frozenset(['h3', 'h4', 'p', 'blockquote', 'pre', 'ul', 'ol', 'figure', 'hr', 'aside'])
# Void elements (no closing tag)
VOID_TAGS = frozenset(['br', 'hr', 'img'])
# Size limit in bytes (60KB threshold, Telegraph limit is 64KB)
SIZE_LIMIT = 61440
def format_table_mono(rows, header_idx=-1):
"""Format table rows as a monospace table using box-drawing chars."""
if not rows:
return ""
n_cols = max(len(row) for row in rows)
widths = [0] * n_cols
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell.strip()))
widths = [max(w, 1) for w in widths]
def sep(left, mid, right, fill='-'):
if fill == '-':
fill = '─'
return left + mid.join(fill * (w + 2) for w in widths) + right
lines = [sep('┌', '┬', '┐')]
for idx, row in enumerate(rows):
cells = []
for i in range(n_cols):
val = row[i].strip() if i < len(row) else ''
cells.append(f" {val:<{widths[i]}} ")
lines.append('│' + '│'.join(cells) + '│')
if idx == header_idx:
lines.append(sep('├', '┼', '┤'))
lines.append(sep('└', '┴', '┘'))
return '\n'.join(lines)
class TelegraphHTMLParser(HTMLParser):
"""Parse HTML fragment into Telegraph Node array."""
def __init__(self):
super().__init__()
self.result = []
self.stack = [] # stack of (tag_or_none, children_list)
self.stack.append((None, self.result))
self._in_table = False
self._table_rows = []
self._current_row = []
self._current_cell = []
self._header_row_idx = -1
self._in_thead = False
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag == 'table':
self._in_table = True
self._table_rows = []
self._current_row = []
self._current_cell = []
self._header_row_idx = -1
self._in_thead = False
return
if self._in_table:
if tag == 'thead':
self._in_thead = True
elif tag == 'tr':
self._current_row = []
elif tag in ('td', 'th'):
self._current_cell = []
if tag == 'th' or self._in_thead:
self._header_row_idx = len(self._table_rows)
return
if tag in ALLOWED_TAGS:
node = {'tag': tag}
filtered_attrs = {}
for k, v in attrs:
if k.lower() in ALLOWED_ATTRS and v:
# Normalize YouTube URLs in iframe src
if k.lower() == 'src' and tag == 'iframe':
v = normalize_youtube_embed(v)
filtered_attrs[k.lower()] = v
if filtered_attrs:
node['attrs'] = filtered_attrs
if tag not in VOID_TAGS:
node['children'] = []
self.stack.append((tag, node['children']))
# Add to parent's children
_, parent_children = self.stack[-1] if tag in VOID_TAGS else self.stack[-2]
parent_children.append(node)
else:
# Unsupported tag: skip tag, children go to parent
pass
def handle_endtag(self, tag):
tag = tag.lower()
if tag == 'table' and self._in_table:
self._in_table = False
if self._table_rows:
_, parent_children = self.stack[-1]
parent_children.append({
'tag': 'pre',
'children': [format_table_mono(self._table_rows, self._header_row_idx)]
})
self._table_rows = []
self._current_row = []
self._current_cell = []
self._header_row_idx = -1
self._in_thead = False
return
if self._in_table:
if tag == 'thead':
self._in_thead = False
elif tag in ('td', 'th'):
self._current_row.append(''.join(self._current_cell))
self._current_cell = []
elif tag == 'tr':
if self._current_row:
self._table_rows.append(self._current_row)
self._current_row = []
return
if tag in ALLOWED_TAGS and tag not in VOID_TAGS:
# Pop from stack if matching
if self.stack and self.stack[-1][0] == tag:
self.stack.pop()
def handle_data(self, data):
if not data.strip() and not data:
return
if self._in_table:
if data:
self._current_cell.append(data)
return
if data:
_, parent_children = self.stack[-1]
parent_children.append(data)
def handle_entityref(self, name):
from html import unescape
char = unescape(f'&{name};')
if self._in_table:
self._current_cell.append(char)
return
_, parent_children = self.stack[-1]
parent_children.append(char)
def handle_charref(self, name):
from html import unescape
char = unescape(f'&#{name};')
if self._in_table:
self._current_cell.append(char)
return
_, parent_children = self.stack[-1]
parent_children.append(char)
def normalize_youtube_embed(url):
"""Normalize YouTube URL to embed format."""
# Already embed format
if '/embed/' in url:
return url
# youtube.com/watch?v=ID or youtu.be/ID
video_id = None
m = re.search(r'[?&]v=([a-zA-Z0-9_-]{11})', url)
if m:
video_id = m.group(1)
else:
m = re.search(r'youtu\.be/([a-zA-Z0-9_-]{11})', url)
if m:
video_id = m.group(1)
if video_id:
return f'https://www.youtube.com/embed/{video_id}'
return url
def html_to_nodes(html_str):
"""Convert HTML string to Telegraph Node array."""
parser = TelegraphHTMLParser()
parser.feed(html_str)
return clean_nodes(parser.result)
def clean_nodes(nodes):
"""Remove empty text nodes and empty elements."""
cleaned = []
for node in nodes:
if isinstance(node, str):
# Skip whitespace-only strings
if not node.strip():
continue
cleaned.append(node)
elif isinstance(node, dict):
if 'children' in node:
node['children'] = clean_nodes(node['children'])
# Remove block elements with no meaningful children
if not node['children'] and node['tag'] not in VOID_TAGS:
if node['tag'] in BLOCK_TAGS:
continue
# Remove empty inline containers too
del node['children']
cleaned.append(node)
return cleaned
def serialize_size(nodes):
"""Return UTF-8 byte size of serialized Node JSON."""
return len(json.dumps(nodes, ensure_ascii=False).encode('utf-8'))
def split_nodes(nodes, max_size=SIZE_LIMIT):
"""Split Node array into parts, each under max_size bytes.
Strategy:
1. Split by top-level h3 boundaries (sections)
2. If a section exceeds limit, split by block-level elements
3. If a single element exceeds limit, raise error
"""
# First, try splitting by h3 sections
sections = []
current_section = []
for node in nodes:
if isinstance(node, dict) and node.get('tag') == 'h3' and current_section:
sections.append(current_section)
current_section = [node]
else:
current_section.append(node)
if current_section:
sections.append(current_section)
# Now pack sections into parts respecting size limit
parts = []
current_part = []
current_size = 2 # for [ ]
for section in sections:
section_size = serialize_size(section)
if section_size > max_size:
# Section too large — split by individual block elements
if current_part:
parts.append(current_part)
current_part = []
current_size = 2
for element in section:
elem_size = serialize_size([element])
if elem_size > max_size:
tag = element.get('tag', 'text') if isinstance(element, dict) else 'text'
print(f"Error: Single element too large ({elem_size} bytes): <{tag}>. Split manually.",
file=sys.stderr)
sys.exit(1)
if current_size + elem_size > max_size:
if current_part:
parts.append(current_part)
current_part = [element]
current_size = 2 + elem_size
else:
current_part.append(element)
current_size += elem_size
else:
if current_size + section_size > max_size:
if current_part:
parts.append(current_part)
current_part = list(section)
current_size = 2 + section_size
else:
current_part.extend(section)
current_size += section_size
if current_part:
parts.append(current_part)
return parts
def main():
args = sys.argv[1:]
if '--check-size' in args:
data = sys.stdin.read()
nodes = json.loads(data)
print(serialize_size(nodes))
return
if '--split' in args:
output_dir = '.'
if '--output-dir' in args:
idx = args.index('--output-dir')
output_dir = args[idx + 1]
data = sys.stdin.read()
nodes = json.loads(data)
parts = split_nodes(nodes)
os.makedirs(output_dir, exist_ok=True)
for i, part in enumerate(parts, 1):
path = os.path.join(output_dir, f'part_{i}.json')
with open(path, 'w', encoding='utf-8') as f:
json.dump(part, f, ensure_ascii=False)
size = serialize_size(part)
print(f"part_{i}.json: {size} bytes", file=sys.stderr)
print(len(parts))
return
# Default: convert HTML from stdin to Node JSON
html_input = sys.stdin.read()
nodes = html_to_nodes(html_input)
print(json.dumps(nodes, ensure_ascii=False))
if __name__ == '__main__':
main()
#!/bin/sh
# Create or manage a Telegraph account
# Usage:
# sh create_account.sh --name "Author Name" [--author-url "https://..."]
# sh create_account.sh --revoke # Rotate token (requires existing token in config)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
# Parse arguments
SHORT_NAME=""
AUTHOR_NAME=""
AUTHOR_URL=""
REVOKE=""
while [ $# -gt 0 ]; do
case "$1" in
--name) SHORT_NAME="$2"; AUTHOR_NAME="$2"; shift 2 ;;
--author-url) AUTHOR_URL="$2"; shift 2 ;;
--revoke) REVOKE="1"; shift ;;
*) shift ;;
esac
done
if [ -n "$REVOKE" ]; then
load_config
echo "Revoking access token and generating new one..." >&2
_result=$(telegraph_post "revokeAccessToken" \
-d "access_token=$TELEGRAPH_ACCESS_TOKEN")
_tmpfile="${TPH_TMPDIR}/telegraph_revoke_$$.json"
printf '%s' "$_result" > "$_tmpfile"
_new_token=$(json_extract_field "$_tmpfile" "access_token")
_auth_url=$(json_extract_field "$_tmpfile" "auth_url")
rm -f "$_tmpfile"
echo "=== Token Revoked ==="
echo "New access_token: $_new_token"
echo "Auth URL (open in browser, valid 5 min): $_auth_url"
echo ""
echo "Update your config/.env with the new token."
exit 0
fi
if [ -z "$SHORT_NAME" ]; then
echo "Usage: sh create_account.sh --name \"Your Name\" [--author-url URL]" >&2
echo " sh create_account.sh --revoke" >&2
exit 1
fi
# Build curl args as proper argv
set -- --data-urlencode "short_name=$SHORT_NAME"
if [ -n "$AUTHOR_NAME" ]; then
set -- "$@" --data-urlencode "author_name=$AUTHOR_NAME"
fi
if [ -n "$AUTHOR_URL" ]; then
set -- "$@" --data-urlencode "author_url=$AUTHOR_URL"
fi
_result=$(telegraph_post "createAccount" "$@")
_tmpfile="${TPH_TMPDIR}/telegraph_create_$$.json"
printf '%s' "$_result" > "$_tmpfile"
_token=$(json_extract_field "$_tmpfile" "access_token")
_auth_url=$(json_extract_field "$_tmpfile" "auth_url")
_short=$(json_extract_field "$_tmpfile" "short_name")
rm -f "$_tmpfile"
echo "=== Telegraph Account Created ==="
echo "Short name: $_short"
echo "Access token: $_token"
echo "Auth URL: $_auth_url"
echo ""
echo "Next steps:"
echo "1. Save the token to config/.env:"
echo " TELEGRAPH_ACCESS_TOKEN=$_token"
echo ""
echo "2. Open Auth URL in browser (valid 5 min, single use)"
echo " to bind this account to your browser session."
echo " After that, pages you create will be visible at telegra.ph"
#!/bin/sh
# Create a Telegraph page with auto-split for large content
# Usage:
# sh create_page.sh --title "My Article" --content-file content.json
# sh create_page.sh --title "My Article" --html-file article.html
# sh create_page.sh --title "My Article" --html "<p>Hello</p>"
# sh create_page.sh --title "My Article" --html-file big.html --author-name "Author"
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
# Parse arguments
TITLE=""
CONTENT_FILE=""
HTML_FILE=""
HTML_INLINE=""
AUTHOR_NAME=""
AUTHOR_URL=""
while [ $# -gt 0 ]; do
case "$1" in
--title) TITLE="$2"; shift 2 ;;
--content-file) CONTENT_FILE="$2"; shift 2 ;;
--html-file) HTML_FILE="$2"; shift 2 ;;
--html) HTML_INLINE="$2"; shift 2 ;;
--author-name) AUTHOR_NAME="$2"; shift 2 ;;
--author-url) AUTHOR_URL="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$TITLE" ]; then
echo "Usage: sh create_page.sh --title TITLE (--content-file FILE | --html-file FILE | --html HTML)" >&2
exit 1
fi
# --------------- Resolve content to Node JSON ---------------
if [ -n "$CONTENT_FILE" ]; then
if [ ! -f "$CONTENT_FILE" ]; then
echo "Error: Content file not found: $CONTENT_FILE" >&2
exit 1
fi
_content=$(cat "$CONTENT_FILE")
elif [ -n "$HTML_FILE" ]; then
if [ ! -f "$HTML_FILE" ]; then
echo "Error: HTML file not found: $HTML_FILE" >&2
exit 1
fi
_content=$(python3 "$SCRIPT_DIR/content_converter.py" < "$HTML_FILE")
elif [ -n "$HTML_INLINE" ]; then
_content=$(printf '%s' "$HTML_INLINE" | python3 "$SCRIPT_DIR/content_converter.py")
else
echo "Error: Provide --content-file, --html-file, or --html" >&2
exit 1
fi
# --------------- Helper: build common author args ---------------
# Appends --data-urlencode author_name/author_url to positional params
# Call: _build_author_args; then use "$@"
_build_author_args() {
if [ -n "$AUTHOR_NAME" ]; then
set -- "$@" --data-urlencode "author_name=$AUTHOR_NAME"
fi
if [ -n "$AUTHOR_URL" ]; then
set -- "$@" --data-urlencode "author_url=$AUTHOR_URL"
fi
# Return args via stdout, one per line — caller collects
}
# --------------- Check size and split if needed ---------------
_size=$(printf '%s' "$_content" | python3 "$SCRIPT_DIR/content_converter.py" --check-size)
if [ "$_size" -gt 61440 ]; then
echo "Content size: ${_size} bytes (limit: 64KB). Auto-splitting..." >&2
# Create work directory
WORK_DIR="${TPH_TMPDIR}/telegraph_split_$$"
mkdir -p "$WORK_DIR"
trap 'rm -rf "$WORK_DIR"' EXIT
# Split content
printf '%s' "$_content" | python3 "$SCRIPT_DIR/content_converter.py" --split --output-dir "$WORK_DIR"
# Count parts
_part_count=$(ls "$WORK_DIR"/part_*.json 2>/dev/null | wc -l | tr -d ' ')
if [ "$_part_count" -eq 0 ]; then
echo "Error: Split produced no parts." >&2
exit 1
fi
echo "Split into $_part_count parts." >&2
# Create child pages (from last to first), collect URLs into a file
_urls_file="$WORK_DIR/part_urls.txt"
: > "$_urls_file"
_part_num="$_part_count"
while [ "$_part_num" -ge 1 ]; do
_part_file="$WORK_DIR/part_${_part_num}.json"
_part_title="${TITLE} — part ${_part_num}"
_part_content=$(cat "$_part_file")
set -- -d "access_token=$TELEGRAPH_ACCESS_TOKEN"
set -- "$@" --data-urlencode "title=$_part_title"
set -- "$@" --data-urlencode "content=$_part_content"
set -- "$@" -d "return_content=false"
if [ -n "$AUTHOR_NAME" ]; then
set -- "$@" --data-urlencode "author_name=$AUTHOR_NAME"
fi
if [ -n "$AUTHOR_URL" ]; then
set -- "$@" --data-urlencode "author_url=$AUTHOR_URL"
fi
_result=$(telegraph_post "createPage" "$@")
_result_file="${WORK_DIR}/result_${_part_num}.json"
printf '%s' "$_result" > "$_result_file"
_url=$(json_extract_field "$_result_file" "url")
echo "${_part_num}|${_url}" >> "$_urls_file"
echo " Part $_part_num: $_url" >&2
_part_num=$(( _part_num - 1 ))
done
# Build index page content via Python (safe JSON construction)
_index_content=$(python3 -c "
import json, sys
lines = open('$_urls_file').read().strip().split('\n')
lines.sort(key=lambda l: int(l.split('|')[0]))
total = len(lines)
nodes = [{'tag': 'p', 'children': ['This article was split into ' + str(total) + ' parts due to size.']}]
for line in lines:
if not line.strip():
continue
num, url = line.strip().split('|', 1)
nodes.append({'tag': 'p', 'children': [{'tag': 'a', 'attrs': {'href': url}, 'children': ['Part ' + num]}]})
print(json.dumps(nodes, ensure_ascii=False))
")
set -- -d "access_token=$TELEGRAPH_ACCESS_TOKEN"
set -- "$@" --data-urlencode "title=$TITLE"
set -- "$@" --data-urlencode "content=$_index_content"
set -- "$@" -d "return_content=false"
if [ -n "$AUTHOR_NAME" ]; then
set -- "$@" --data-urlencode "author_name=$AUTHOR_NAME"
fi
if [ -n "$AUTHOR_URL" ]; then
set -- "$@" --data-urlencode "author_url=$AUTHOR_URL"
fi
_result=$(telegraph_post "createPage" "$@")
_result_file="${WORK_DIR}/result_index.json"
printf '%s' "$_result" > "$_result_file"
_url=$(json_extract_field "$_result_file" "url")
_path=$(json_extract_field "$_result_file" "path")
echo ""
echo "=== Article Published (multi-part) ==="
echo "Index URL: $_url"
echo "Path: $_path"
echo "Parts: $_part_count"
exit 0
fi
# --------------- Single page publish ---------------
set -- -d "access_token=$TELEGRAPH_ACCESS_TOKEN"
set -- "$@" --data-urlencode "title=$TITLE"
set -- "$@" --data-urlencode "content=$_content"
set -- "$@" -d "return_content=false"
if [ -n "$AUTHOR_NAME" ]; then
set -- "$@" --data-urlencode "author_name=$AUTHOR_NAME"
fi
if [ -n "$AUTHOR_URL" ]; then
set -- "$@" --data-urlencode "author_url=$AUTHOR_URL"
fi
_result=$(telegraph_post "createPage" "$@")
_tmpfile="${TPH_TMPDIR}/telegraph_create_page_$$.json"
printf '%s' "$_result" > "$_tmpfile"
_url=$(json_extract_field "$_tmpfile" "url")
_path=$(json_extract_field "$_tmpfile" "path")
rm -f "$_tmpfile"
echo "=== Page Published ==="
echo "URL: $_url"
echo "Path: $_path"
#!/usr/bin/env python3
"""Encode diagram source for PlantUML/Mermaid public rendering servers.
Usage:
python3 diagram_encode.py plantuml < diagram.puml
python3 diagram_encode.py mermaid < diagram.mmd
echo "graph LR; A-->B" | python3 diagram_encode.py mermaid
Output: full render URL
PlantUML: https://www.plantuml.com/plantuml/png/ENCODED
Mermaid: https://mermaid.ink/img/pako:ENCODED
"""
import base64
import json
import sys
import zlib
# --------------- PlantUML encoding ---------------
# PlantUML uses a custom 6-bit encoding with a specific alphabet
PLANTUML_ALPHABET = (
'0123456789'
'ABCDEFGHIJ'
'KLMNOPQRST'
'UVWXYZ'
'abcdefghij'
'klmnopqrst'
'uvwxyz'
'-_'
)
def _plantuml_encode_6bit(b):
"""Encode a single 6-bit value to PlantUML alphabet char."""
if 0 <= b < len(PLANTUML_ALPHABET):
return PLANTUML_ALPHABET[b]
return '?'
def _plantuml_encode_3bytes(b1, b2, b3):
"""Encode 3 bytes into 4 PlantUML chars."""
c1 = b1 >> 2
c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
c3 = ((b2 & 0xF) << 2) | (b3 >> 6)
c4 = b3 & 0x3F
return (
_plantuml_encode_6bit(c1)
+ _plantuml_encode_6bit(c2)
+ _plantuml_encode_6bit(c3)
+ _plantuml_encode_6bit(c4)
)
def encode_plantuml(text):
"""Encode PlantUML source to URL-safe string for plantuml.com."""
data = zlib.compress(text.encode('utf-8'), 9)
# Strip zlib header (2 bytes) and checksum (4 bytes) for raw deflate
data = data[2:-4]
result = ''
i = 0
while i < len(data):
if i + 2 < len(data):
result += _plantuml_encode_3bytes(data[i], data[i + 1], data[i + 2])
elif i + 1 < len(data):
result += _plantuml_encode_3bytes(data[i], data[i + 1], 0)
else:
result += _plantuml_encode_3bytes(data[i], 0, 0)
i += 3
return f'https://www.plantuml.com/plantuml/png/{result}'
# --------------- Mermaid encoding ---------------
def encode_mermaid(text):
"""Encode Mermaid source to URL for mermaid.ink.
Format: https://mermaid.ink/img/pako:PAYLOAD
PAYLOAD = URL-safe base64 (no padding) of raw deflate of compact JSON.
"""
payload = json.dumps(
{'code': text, 'mermaid': {'theme': 'default'}},
separators=(',', ':'),
ensure_ascii=False
)
# pako.deflate output (full zlib with header+checksum, not raw deflate)
compressed = zlib.compress(payload.encode('utf-8'), 9)
# URL-safe base64 without padding
encoded = base64.urlsafe_b64encode(compressed).rstrip(b'=').decode('ascii')
return f'https://mermaid.ink/img/pako:{encoded}'
# --------------- CLI ---------------
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ('plantuml', 'mermaid'):
print('Usage: python3 diagram_encode.py plantuml|mermaid < source', file=sys.stderr)
sys.exit(1)
diagram_type = sys.argv[1]
source = sys.stdin.read()
if not source.strip():
print('Error: Empty diagram source.', file=sys.stderr)
sys.exit(1)
if diagram_type == 'plantuml':
print(encode_plantuml(source))
else:
print(encode_mermaid(source))
if __name__ == '__main__':
main()
#!/bin/sh
# Edit an existing Telegraph page
# Usage:
# sh edit_page.sh --path "Page-Title-03-09" --title "New Title" --content-file content.json
# sh edit_page.sh --path "Page-Title-03-09" --title "New Title" --html-file article.html
# sh edit_page.sh --path "Page-Title-03-09" --title "New Title" --html "<p>Inline HTML</p>"
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
# Parse arguments
PAGE_PATH=""
TITLE=""
CONTENT_FILE=""
HTML_FILE=""
HTML_INLINE=""
AUTHOR_NAME=""
AUTHOR_URL=""
while [ $# -gt 0 ]; do
case "$1" in
--path) PAGE_PATH="$2"; shift 2 ;;
--title) TITLE="$2"; shift 2 ;;
--content-file) CONTENT_FILE="$2"; shift 2 ;;
--html-file) HTML_FILE="$2"; shift 2 ;;
--html) HTML_INLINE="$2"; shift 2 ;;
--author-name) AUTHOR_NAME="$2"; shift 2 ;;
--author-url) AUTHOR_URL="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$PAGE_PATH" ] || [ -z "$TITLE" ]; then
echo "Usage: sh edit_page.sh --path PATH --title TITLE (--content-file FILE | --html-file FILE | --html HTML)" >&2
exit 1
fi
# Resolve content
if [ -n "$CONTENT_FILE" ]; then
if [ ! -f "$CONTENT_FILE" ]; then
echo "Error: Content file not found: $CONTENT_FILE" >&2
exit 1
fi
_content=$(cat "$CONTENT_FILE")
elif [ -n "$HTML_FILE" ]; then
if [ ! -f "$HTML_FILE" ]; then
echo "Error: HTML file not found: $HTML_FILE" >&2
exit 1
fi
_content=$(python3 "$SCRIPT_DIR/content_converter.py" < "$HTML_FILE")
elif [ -n "$HTML_INLINE" ]; then
_content=$(printf '%s' "$HTML_INLINE" | python3 "$SCRIPT_DIR/content_converter.py")
else
echo "Error: Provide --content-file, --html-file, or --html" >&2
exit 1
fi
# Build request as proper argv
set -- -d "access_token=$TELEGRAPH_ACCESS_TOKEN"
set -- "$@" --data-urlencode "title=$TITLE"
set -- "$@" --data-urlencode "content=$_content"
set -- "$@" -d "return_content=false"
if [ -n "$AUTHOR_NAME" ]; then
set -- "$@" --data-urlencode "author_name=$AUTHOR_NAME"
fi
if [ -n "$AUTHOR_URL" ]; then
set -- "$@" --data-urlencode "author_url=$AUTHOR_URL"
fi
_result=$(telegraph_post "editPage/$PAGE_PATH" "$@")
_tmpfile="${TPH_TMPDIR}/telegraph_edit_$$.json"
printf '%s' "$_result" > "$_tmpfile"
_url=$(json_extract_field "$_tmpfile" "url")
_path=$(json_extract_field "$_tmpfile" "path")
rm -f "$_tmpfile"
echo "=== Page Updated ==="
echo "URL: $_url"
echo "Path: $_path"
#!/bin/sh
# Delete all GitHub-backed assets for a Telegraph page using its manifest.
#
# Usage:
# sh github_delete_page_assets.sh --page-path my-page-path
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
check_prerequisites
load_github_config
PAGE_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
--page-path) PAGE_PATH="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$PAGE_PATH" ]; then
echo "Usage: sh github_delete_page_assets.sh --page-path TELEGRAPH_PATH" >&2
exit 1
fi
TMP_DIR=$(make_secure_tmpdir)
trap 'rm -rf "$TMP_DIR"' EXIT
MANIFEST_REPO_PATH="${GITHUB_MANIFESTS_DIR%/}/${PAGE_PATH}.json"
MANIFEST_RESPONSE="$TMP_DIR/manifest_response.json"
MANIFEST_BODY="$TMP_DIR/manifest_body.json"
ASSETS_LIST="$TMP_DIR/assets.tsv"
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28"
MANIFEST_GET_CODE=$(curl -sS -o "$MANIFEST_RESPONSE" -w '%{http_code}' "$@" \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${MANIFEST_REPO_PATH}?ref=${GITHUB_ASSETS_BRANCH}")
case "$MANIFEST_GET_CODE" in
200) ;;
404)
echo "Error: Manifest not found for page path: ${PAGE_PATH}" >&2
echo "Expected: ${MANIFEST_REPO_PATH}" >&2
exit 1
;;
*)
echo "Error: GitHub manifest lookup failed for ${MANIFEST_REPO_PATH} (HTTP ${MANIFEST_GET_CODE})." >&2
cat "$MANIFEST_RESPONSE" >&2
exit 1
;;
esac
MANIFEST_SHA=$(python3 - "$MANIFEST_RESPONSE" "$MANIFEST_BODY" <<'PY'
import base64, json, sys
data = json.load(open(sys.argv[1]))
print(data.get("sha", ""))
content = data.get("content", "")
if content:
decoded = base64.b64decode(content)
open(sys.argv[2], "w", encoding="utf-8").write(decoded.decode("utf-8"))
PY
)
python3 "$SCRIPT_DIR/github_manifest.py" list < "$MANIFEST_BODY" > "$ASSETS_LIST"
DELETED_COUNT=0
TAB=$(printf '\t')
while IFS="$TAB" read -r ASSET_PATH ASSET_SHA ASSET_CDN_URL ASSET_COMMIT_SHA; do
[ -z "$ASSET_PATH" ] && continue
if [ -z "$ASSET_SHA" ]; then
echo "Warning: Missing SHA for asset ${ASSET_PATH}, fetching current SHA..." >&2
ASSET_META_RESPONSE="$TMP_DIR/asset_meta_$$.json"
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28"
ASSET_META_CODE=$(curl -sS -o "$ASSET_META_RESPONSE" -w '%{http_code}' "$@" \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${ASSET_PATH}?ref=${GITHUB_ASSETS_BRANCH}")
case "$ASSET_META_CODE" in
200)
ASSET_SHA=$(python3 - "$ASSET_META_RESPONSE" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
print(data.get("sha", ""))
PY
)
;;
404)
echo "Warning: Asset already missing: ${ASSET_PATH}" >&2
continue
;;
*)
echo "Error: Failed to fetch SHA for ${ASSET_PATH} (HTTP ${ASSET_META_CODE})." >&2
cat "$ASSET_META_RESPONSE" >&2
exit 1
;;
esac
fi
DELETE_PAYLOAD=$(python3 - "$ASSET_PATH" "$ASSET_SHA" "$GITHUB_ASSETS_BRANCH" <<'PY'
import json, sys
asset_path, sha, branch = sys.argv[1:4]
print(json.dumps({
"message": f"telegraph-publisher: delete {asset_path}",
"sha": sha,
"branch": branch,
}, separators=(",", ":")))
PY
)
ASSET_DELETE_RESPONSE="$TMP_DIR/delete_$$.json"
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json"
ASSET_DELETE_CODE=$(printf '%s' "$DELETE_PAYLOAD" | curl -sS -o "$ASSET_DELETE_RESPONSE" -w '%{http_code}' "$@" \
-X DELETE \
-d @- \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${ASSET_PATH}")
case "$ASSET_DELETE_CODE" in
200)
DELETED_COUNT=$(( DELETED_COUNT + 1 ))
echo "Deleted asset: ${ASSET_PATH}" >&2
;;
404)
echo "Warning: Asset already missing: ${ASSET_PATH}" >&2
;;
*)
echo "Error: Failed to delete asset ${ASSET_PATH} (HTTP ${ASSET_DELETE_CODE})." >&2
cat "$ASSET_DELETE_RESPONSE" >&2
exit 1
;;
esac
done < "$ASSETS_LIST"
MANIFEST_DELETE_PAYLOAD=$(python3 - "$MANIFEST_REPO_PATH" "$MANIFEST_SHA" "$GITHUB_ASSETS_BRANCH" <<'PY'
import json, sys
manifest_path, sha, branch = sys.argv[1:4]
print(json.dumps({
"message": f"telegraph-publisher: delete manifest {manifest_path}",
"sha": sha,
"branch": branch,
}, separators=(",", ":")))
PY
)
MANIFEST_DELETE_RESPONSE="$TMP_DIR/delete_manifest.json"
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json"
MANIFEST_DELETE_CODE=$(printf '%s' "$MANIFEST_DELETE_PAYLOAD" | curl -sS -o "$MANIFEST_DELETE_RESPONSE" -w '%{http_code}' "$@" \
-X DELETE \
-d @- \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${MANIFEST_REPO_PATH}")
case "$MANIFEST_DELETE_CODE" in
200)
echo "Deleted manifest: ${MANIFEST_REPO_PATH}" >&2
;;
*)
echo "Error: Failed to delete manifest ${MANIFEST_REPO_PATH} (HTTP ${MANIFEST_DELETE_CODE})." >&2
cat "$MANIFEST_DELETE_RESPONSE" >&2
exit 1
;;
esac
echo "Deleted ${DELETED_COUNT} asset(s) for page path ${PAGE_PATH}."
#!/usr/bin/env python3
"""Helpers for Telegraph GitHub asset manifests.
Commands:
merge <page_path> <asset_path> <asset_sha> <cdn_url> <commit_sha>
list
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
def utc_now() -> str:
return (
datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def read_manifest() -> dict:
raw = sys.stdin.read().strip()
if not raw:
return {}
return json.loads(raw)
def cmd_merge(args: list[str]) -> int:
if len(args) != 5:
print(
"Usage: github_manifest.py merge <page_path> <asset_path> <asset_sha> <cdn_url> <commit_sha>",
file=sys.stderr,
)
return 1
page_path, asset_path, asset_sha, cdn_url, commit_sha = args
manifest = read_manifest() or {}
assets = manifest.get("assets", [])
updated_asset = {
"path": asset_path,
"sha": asset_sha,
"cdn_url": cdn_url,
"commit_sha": commit_sha,
}
replaced = False
result_assets = []
for asset in assets:
if asset.get("path") == asset_path:
result_assets.append(updated_asset)
replaced = True
else:
result_assets.append(asset)
if not replaced:
result_assets.append(updated_asset)
result_assets.sort(key=lambda item: item.get("path", ""))
manifest = {
"path": page_path,
"assets": result_assets,
"updated_at": utc_now(),
}
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
def cmd_list() -> int:
manifest = read_manifest()
for asset in manifest.get("assets", []):
print(
"\t".join(
[
asset.get("path", ""),
asset.get("sha", ""),
asset.get("cdn_url", ""),
asset.get("commit_sha", ""),
]
)
)
return 0
def main() -> int:
if len(sys.argv) < 2:
print("Usage: github_manifest.py <merge|list> ...", file=sys.stderr)
return 1
cmd = sys.argv[1]
if cmd == "merge":
return cmd_merge(sys.argv[2:])
if cmd == "list":
return cmd_list()
print(f"Unknown command: {cmd}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
#!/bin/sh
# Upload a local asset to GitHub Contents API and register it in the page manifest.
#
# Usage:
# sh github_upload.sh --file ./hero.webp --page-path my-page-path
# sh github_upload.sh --file ./hero.webp --page-path my-page-path --name hero.webp
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
check_prerequisites
load_github_config
FILE=""
PAGE_PATH=""
ASSET_NAME=""
while [ $# -gt 0 ]; do
case "$1" in
--file) FILE="$2"; shift 2 ;;
--page-path) PAGE_PATH="$2"; shift 2 ;;
--name) ASSET_NAME="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$FILE" ] || [ -z "$PAGE_PATH" ]; then
echo "Usage: sh github_upload.sh --file FILE --page-path TELEGRAPH_PATH [--name NAME]" >&2
exit 1
fi
if [ ! -f "$FILE" ]; then
echo "Error: File not found: $FILE" >&2
exit 1
fi
if [ -z "$ASSET_NAME" ]; then
ASSET_NAME=$(slugify_filename "$FILE")
fi
TMP_DIR=$(make_secure_tmpdir)
trap 'rm -rf "$TMP_DIR"' EXIT
REPO_PATH="${GITHUB_ASSETS_BASE_DIR%/}/${PAGE_PATH}/${ASSET_NAME}"
MANIFEST_REPO_PATH="${GITHUB_MANIFESTS_DIR%/}/${PAGE_PATH}.json"
FILE_B64=$(base64 < "$FILE" | tr -d '\n')
ASSET_GET_RESPONSE="$TMP_DIR/asset_get.json"
ASSET_PUT_RESPONSE="$TMP_DIR/asset_put.json"
MANIFEST_GET_RESPONSE="$TMP_DIR/manifest_get.json"
MANIFEST_BODY="$TMP_DIR/manifest_body.json"
MANIFEST_PUT_RESPONSE="$TMP_DIR/manifest_put.json"
ASSET_SHA=""
MANIFEST_SHA=""
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28"
ASSET_GET_CODE=$(curl -sS -o "$ASSET_GET_RESPONSE" -w '%{http_code}' "$@" \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${REPO_PATH}?ref=${GITHUB_ASSETS_BRANCH}")
case "$ASSET_GET_CODE" in
200)
ASSET_SHA=$(python3 - "$ASSET_GET_RESPONSE" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
print(data.get("sha", ""))
PY
)
;;
404) ;;
*)
echo "Error: GitHub asset lookup failed for ${REPO_PATH} (HTTP ${ASSET_GET_CODE})." >&2
cat "$ASSET_GET_RESPONSE" >&2
exit 1
;;
esac
ASSET_PAYLOAD=$(python3 - "$REPO_PATH" "$ASSET_SHA" "$GITHUB_ASSETS_BRANCH" "$FILE_B64" <<'PY'
import json, sys
repo_path, sha, branch, content = sys.argv[1:5]
payload = {
"message": f"telegraph-publisher: upsert {repo_path}",
"content": content,
"branch": branch,
}
if sha:
payload["sha"] = sha
print(json.dumps(payload, separators=(",", ":")))
PY
)
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json"
ASSET_PUT_CODE=$(printf '%s' "$ASSET_PAYLOAD" | curl -sS -o "$ASSET_PUT_RESPONSE" -w '%{http_code}' "$@" \
-X PUT \
-d @- \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${REPO_PATH}")
case "$ASSET_PUT_CODE" in
200|201) ;;
*)
echo "Error: GitHub asset upload failed for ${REPO_PATH} (HTTP ${ASSET_PUT_CODE})." >&2
cat "$ASSET_PUT_RESPONSE" >&2
exit 1
;;
esac
ASSET_META=$(python3 - "$ASSET_PUT_RESPONSE" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
content = data.get("content", {})
commit = data.get("commit", {})
print(content.get("path", ""))
print(content.get("sha", ""))
print(commit.get("sha", ""))
PY
)
ASSET_PATH=$(printf '%s\n' "$ASSET_META" | sed -n '1p')
ASSET_SHA=$(printf '%s\n' "$ASSET_META" | sed -n '2p')
COMMIT_SHA=$(printf '%s\n' "$ASSET_META" | sed -n '3p')
if [ -z "$ASSET_PATH" ] || [ -z "$ASSET_SHA" ] || [ -z "$COMMIT_SHA" ]; then
echo "Error: Unexpected GitHub upload response." >&2
cat "$ASSET_PUT_RESPONSE" >&2
exit 1
fi
CDN_URL="https://cdn.jsdelivr.net/gh/${GITHUB_ASSETS_REPO}@${COMMIT_SHA}/${ASSET_PATH}"
: > "$MANIFEST_BODY"
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28"
MANIFEST_GET_CODE=$(curl -sS -o "$MANIFEST_GET_RESPONSE" -w '%{http_code}' "$@" \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${MANIFEST_REPO_PATH}?ref=${GITHUB_ASSETS_BRANCH}")
case "$MANIFEST_GET_CODE" in
200)
MANIFEST_SHA=$(python3 - "$MANIFEST_GET_RESPONSE" "$MANIFEST_BODY" <<'PY'
import base64, json, sys
data = json.load(open(sys.argv[1]))
print(data.get("sha", ""))
content = data.get("content", "")
if content:
decoded = base64.b64decode(content)
open(sys.argv[2], "w", encoding="utf-8").write(decoded.decode("utf-8"))
PY
)
;;
404) ;;
*)
echo "Error: GitHub manifest lookup failed for ${MANIFEST_REPO_PATH} (HTTP ${MANIFEST_GET_CODE})." >&2
cat "$MANIFEST_GET_RESPONSE" >&2
exit 1
;;
esac
MANIFEST_JSON=$(python3 "$SCRIPT_DIR/github_manifest.py" merge \
"$PAGE_PATH" "$ASSET_PATH" "$ASSET_SHA" "$CDN_URL" "$COMMIT_SHA" < "$MANIFEST_BODY")
MANIFEST_B64=$(printf '%s' "$MANIFEST_JSON" | base64 | tr -d '\n')
MANIFEST_PAYLOAD=$(python3 - "$MANIFEST_REPO_PATH" "$MANIFEST_SHA" "$GITHUB_ASSETS_BRANCH" "$MANIFEST_B64" <<'PY'
import json, sys
repo_path, sha, branch, content = sys.argv[1:5]
payload = {
"message": f"telegraph-publisher: update manifest {repo_path}",
"content": content,
"branch": branch,
}
if sha:
payload["sha"] = sha
print(json.dumps(payload, separators=(",", ":")))
PY
)
set -- \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json"
MANIFEST_PUT_CODE=$(printf '%s' "$MANIFEST_PAYLOAD" | curl -sS -o "$MANIFEST_PUT_RESPONSE" -w '%{http_code}' "$@" \
-X PUT \
-d @- \
"${GITHUB_API}/repos/${GITHUB_ASSETS_REPO}/contents/${MANIFEST_REPO_PATH}")
case "$MANIFEST_PUT_CODE" in
200|201) ;;
*)
echo "Error: GitHub manifest update failed for ${MANIFEST_REPO_PATH} (HTTP ${MANIFEST_PUT_CODE})." >&2
cat "$MANIFEST_PUT_RESPONSE" >&2
exit 1
;;
esac
echo "$CDN_URL"
echo "GitHub asset stored: ${ASSET_PATH}" >&2
echo "Manifest updated: ${MANIFEST_REPO_PATH}" >&2
#!/bin/sh
# List Telegraph pages for the account
# Usage: sh list_pages.sh [--offset 0] [--limit 50]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
# Parse arguments
OFFSET=0
LIMIT=50
while [ $# -gt 0 ]; do
case "$1" in
--offset) OFFSET="$2"; shift 2 ;;
--limit) LIMIT="$2"; shift 2 ;;
*) shift ;;
esac
done
_result=$(telegraph_post "getPageList" \
-d "access_token=$TELEGRAPH_ACCESS_TOKEN" \
-d "offset=$OFFSET" \
-d "limit=$LIMIT")
echo "$_result" | python3 "$SCRIPT_DIR/parse_response.py" page_list
#!/usr/bin/env python3
"""Parse Telegraph API JSON responses into human-readable output.
Usage:
echo '{"ok":true,"result":{...}}' | python3 parse_response.py account_info
echo '{"ok":true,"result":{...}}' | python3 parse_response.py page_list
"""
import json
import sys
def parse_account_info(data):
"""Format getAccountInfo response."""
result = data.get('result', {})
lines = ['=== Account Info ===']
if 'short_name' in result:
lines.append(f"Short name: {result['short_name']}")
if 'author_name' in result:
lines.append(f"Author name: {result['author_name']}")
if 'author_url' in result:
lines.append(f"Author URL: {result['author_url']}")
if 'page_count' in result:
lines.append(f"Page count: {result['page_count']}")
if 'auth_url' in result:
lines.append(f"Auth URL: {result['auth_url']}")
lines.append(" (open in browser to bind account, valid 5 min)")
return '\n'.join(lines)
def parse_page_list(data):
"""Format getPageList response as a table."""
result = data.get('result', {})
total = result.get('total_count', 0)
pages = result.get('pages', [])
lines = [f'=== Pages ({total} total) ===']
if not pages:
lines.append('No pages found.')
return '\n'.join(lines)
# Header
lines.append(f"{'#':<4} {'Title':<50} {'URL':<40} {'Views':<8}")
lines.append('-' * 102)
for i, page in enumerate(pages, 1):
title = page.get('title', '(no title)')
if len(title) > 48:
title = title[:45] + '...'
url = page.get('url', '')
views = page.get('views', 0)
lines.append(f"{i:<4} {title:<50} {url:<40} {views:<8}")
if len(pages) < total:
lines.append(f"\n... showing {len(pages)} of {total}. Use --offset/--limit for more.")
return '\n'.join(lines)
def main():
if len(sys.argv) < 2:
print("Usage: parse_response.py <command>", file=sys.stderr)
print("Commands: account_info, page_list", file=sys.stderr)
sys.exit(1)
command = sys.argv[1]
data = json.loads(sys.stdin.read(), strict=False)
if command == 'account_info':
print(parse_account_info(data))
elif command == 'page_list':
print(parse_page_list(data))
else:
# Fallback: pretty-print JSON
print(json.dumps(data, indent=2, ensure_ascii=False))
if __name__ == '__main__':
main()
#!/bin/sh
# Render a PlantUML or Mermaid diagram via public rendering server
#
# Usage:
# sh render_diagram.sh --type plantuml --file diagram.puml
# sh render_diagram.sh --type mermaid --file diagram.mmd
# sh render_diagram.sh --type mermaid --file diagram.mmd --upload
#
# Without --upload: outputs render URL (image hosted on public server)
# With --upload: downloads PNG, uploads to Telegraph, outputs Telegraph URL
#
# PRIVACY: Diagram source is sent to a public server (plantuml.com / mermaid.ink).
# Do not use for confidential content.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
# Parse arguments
DIAGRAM_TYPE=""
DIAGRAM_FILE=""
DO_UPLOAD=""
INSECURE=""
GITHUB_PAGE_PATH=""
GITHUB_NAME=""
while [ $# -gt 0 ]; do
case "$1" in
--type) DIAGRAM_TYPE="$2"; shift 2 ;;
--file) DIAGRAM_FILE="$2"; shift 2 ;;
--upload) DO_UPLOAD="1"; shift ;;
--insecure) INSECURE="1"; shift ;;
--github-page-path) GITHUB_PAGE_PATH="$2"; shift 2 ;;
--github-name) GITHUB_NAME="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$DIAGRAM_TYPE" ] || [ -z "$DIAGRAM_FILE" ]; then
echo "Usage: sh render_diagram.sh --type plantuml|mermaid --file FILE [--upload] [--github-page-path PATH] [--github-name NAME]" >&2
exit 1
fi
case "$DIAGRAM_TYPE" in
plantuml|mermaid) ;;
*)
echo "Error: Unsupported diagram type: $DIAGRAM_TYPE" >&2
echo "Supported: plantuml, mermaid" >&2
exit 1
;;
esac
if [ ! -f "$DIAGRAM_FILE" ]; then
echo "Error: File not found: $DIAGRAM_FILE" >&2
exit 1
fi
# Privacy warning
echo "WARNING: Diagram source will be sent to a public rendering server." >&2
case "$DIAGRAM_TYPE" in
plantuml) echo "Server: plantuml.com" >&2 ;;
mermaid) echo "Server: mermaid.ink" >&2 ;;
esac
echo "Do not use for confidential content." >&2
echo "" >&2
# Get render URL
_render_url=$(python3 "$SCRIPT_DIR/diagram_encode.py" "$DIAGRAM_TYPE" < "$DIAGRAM_FILE")
if [ -z "$_render_url" ]; then
echo "Error: Failed to encode diagram." >&2
exit 1
fi
if [ -z "$DO_UPLOAD" ] && [ -z "$GITHUB_PAGE_PATH" ]; then
# Just output the render URL
echo "$_render_url"
exit 0
fi
# Download and upload to Telegraph
echo "Downloading rendered diagram..." >&2
_tmpdir="${TPH_TMPDIR}/telegraph_diagram_$$"
mkdir -p "$_tmpdir"
trap 'rm -rf "$_tmpdir"' EXIT
_png_file="$_tmpdir/diagram.png"
_headers_file="$_tmpdir/headers.txt"
set -- -s -f -w '%{http_code}' -o "$_png_file" -D "$_headers_file"
if [ -n "$INSECURE" ]; then
set -- "$@" -k
fi
_http_code=$(curl "$@" "$_render_url" 2>/dev/null) || {
echo "Error: Failed to download diagram from rendering server." >&2
echo "URL: $_render_url" >&2
if [ "$DIAGRAM_TYPE" = "plantuml" ]; then
echo "Note: PlantUML server may return an error image instead of HTTP error for invalid diagrams." >&2
echo "If the download succeeded but the image shows an error, check your diagram syntax." >&2
fi
exit 1
}
# Verify content-type is image
_content_type=$(grep -i 'content-type' "$_headers_file" | head -1 | sed 's/.*:[[:space:]]*//' | tr -d '\r\n' | tr '[:upper:]' '[:lower:]')
case "$_content_type" in
image/*) ;;
*)
echo "Warning: Unexpected content-type: $_content_type" >&2
echo "Expected image/*. The rendering server may have returned an error." >&2
;;
esac
# Check file is not empty
_file_size=$(wc -c < "$_png_file" | tr -d ' ')
if [ "$_file_size" -eq 0 ]; then
echo "Error: Downloaded file is empty." >&2
exit 1
fi
if [ -n "$GITHUB_PAGE_PATH" ]; then
echo "Downloaded ${_file_size} bytes. Uploading to GitHub assets..." >&2
echo "Uploading diagram to GitHub assets..." >&2
set -- --file "$_png_file" --page-path "$GITHUB_PAGE_PATH"
if [ -n "$GITHUB_NAME" ]; then
set -- "$@" --name "$GITHUB_NAME"
fi
_github_url=$(sh "$SCRIPT_DIR/github_upload.sh" "$@" 2>/dev/null) || {
echo "Error: Upload to GitHub assets failed." >&2
echo "Render URL (use directly): $_render_url" >&2
exit 1
}
echo "$_github_url"
elif [ -n "$DO_UPLOAD" ]; then
echo "Downloaded ${_file_size} bytes. Uploading to Telegraph..." >&2
set -- --file "$_png_file"
if [ -n "$INSECURE" ]; then
set -- "$@" --insecure
fi
_telegraph_url=$(sh "$SCRIPT_DIR/upload.sh" "$@" 2>/dev/null) || {
echo "Error: Upload to Telegraph failed." >&2
echo "Render URL (use directly): $_render_url" >&2
exit 1
}
echo "$_telegraph_url"
fi
if [ "$DIAGRAM_TYPE" = "plantuml" ]; then
echo "" >&2
echo "Note: PlantUML server does not return HTTP errors for invalid diagrams." >&2
echo "Verify the rendered image visually before publishing." >&2
fi
#!/bin/sh
# Upload a local image/video to Telegraph
# Uses unofficial but stable telegra.ph/upload endpoint (no token required)
#
# Usage:
# sh upload.sh --file /path/to/image.png
# sh upload.sh --file /path/to/image.png --insecure # skip SSL verification
#
# Output: full URL (https://telegra.ph/file/...)
#
# NOTE: This endpoint is NOT part of the official Telegraph API.
# It may change or become unavailable without notice.
# May fail behind corporate proxies/VPNs that intercept HTTPS.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
# Parse arguments
FILE=""
INSECURE=""
while [ $# -gt 0 ]; do
case "$1" in
--file) FILE="$2"; shift 2 ;;
--insecure) INSECURE="1"; shift ;;
*) shift ;;
esac
done
if [ -z "$FILE" ]; then
echo "Usage: sh upload.sh --file /path/to/image.png [--insecure]" >&2
exit 1
fi
if [ ! -f "$FILE" ]; then
echo "Error: File not found: $FILE" >&2
exit 1
fi
# --------------- Validation ---------------
# Check file extension
_ext=$(echo "$FILE" | sed 's/.*\.//' | tr '[:upper:]' '[:lower:]')
case "$_ext" in
jpg|jpeg|png|gif|webp|mp4) ;;
*)
echo "Error: Unsupported file type: .$_ext" >&2
echo "Supported: jpg, jpeg, png, gif, webp, mp4" >&2
exit 1
;;
esac
# Check file size (<5MB)
_size=$(wc -c < "$FILE" | tr -d ' ')
_max_size=5242880
if [ "$_size" -gt "$_max_size" ]; then
echo "Error: File too large ($(( _size / 1024 ))KB). Maximum: 5MB." >&2
exit 1
fi
# Best-effort MIME type check (if 'file' command available)
if command -v file >/dev/null 2>&1; then
_mime=$(file --mime-type -b "$FILE" 2>/dev/null || true)
if [ -n "$_mime" ]; then
case "$_mime" in
image/*|video/*) ;;
*)
echo "Warning: MIME type '$_mime' does not look like image/video." >&2
echo "Proceeding anyway (extension-based check passed)." >&2
;;
esac
fi
fi
# --------------- Upload ---------------
echo "Uploading $(( _size / 1024 ))KB file to Telegraph..." >&2
_tmpfile="${TPH_TMPDIR}/telegraph_upload_$$.json"
trap 'rm -f "$_tmpfile"' EXIT
# Build curl args
set -- -s -w '%{http_code}' -o "$_tmpfile"
# Skip SSL verification for HTTPS-intercepting proxies/VPNs
if [ -n "$INSECURE" ]; then
set -- "$@" -k
echo "Warning: SSL verification disabled (--insecure mode)." >&2
fi
set -- "$@" -F "file=@$FILE" "https://telegra.ph/upload"
_http_code=$(curl "$@")
if [ "$_http_code" -ge 400 ] 2>/dev/null; then
echo "Error: Upload failed with HTTP $_http_code" >&2
cat "$_tmpfile" >&2
echo "" >&2
echo "Possible causes:" >&2
echo " - Corporate proxy/VPN intercepting HTTPS (try --insecure)" >&2
echo " - Telegraph upload endpoint temporarily unavailable" >&2
echo "Fallback: use a public image URL directly." >&2
exit 1
fi
# Parse response: expect [{"src":"/file/..."}]
_src=$(grep -o '"src"[[:space:]]*:[[:space:]]*"[^"]*"' "$_tmpfile" | head -1 | sed 's/.*"src"[[:space:]]*:[[:space:]]*"//;s/"$//')
if [ -z "$_src" ]; then
echo "Error: Unexpected response format from upload endpoint:" >&2
cat "$_tmpfile" >&2
echo "" >&2
echo "Fallback: use a public image URL directly." >&2
exit 1
fi
_full_url="https://telegra.ph${_src}"
echo "$_full_url"
echo "Uploaded successfully." >&2