diff --git a/.github/workflows/tweet.yml b/.github/workflows/tweet.yml new file mode 100644 index 0000000..dfaac72 --- /dev/null +++ b/.github/workflows/tweet.yml @@ -0,0 +1,48 @@ +name: Tweet New Posts + +on: + push: + branches: [main] + paths: + - "content/*/module.md" + +jobs: + tweet: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find new blog posts + id: changed + run: | + changed=$(git diff --name-only --diff-filter=A ${{ github.event.before }}..${{ github.sha }} -- 'content/*/module.md' | grep -v '^content/docs' || true) + echo "files<> "$GITHUB_OUTPUT" + echo "$changed" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Tweet new posts + if: steps.changed.outputs.files != '' + env: + X_API_KEY: ${{ secrets.X_API_KEY }} + X_API_SECRET: ${{ secrets.X_API_SECRET }} + X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }} + X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }} + run: | + echo "${{ steps.changed.outputs.files }}" | while IFS= read -r file; do + if [[ -z "$file" ]]; then continue; fi + if [[ ! -f "$file" ]]; then + echo "Skipping $file — file does not exist" + continue + fi + slug=$(basename "$(dirname "$file")") + tweet_field=$(awk '/
/{f=1;next} /<\/details>/{if(f)exit} f' "$file" | grep '^tweet:' | head -1) + if [[ -z "$tweet_field" ]]; then + echo "Skipping $slug — no tweet field" + continue + fi + echo "Tweeting for: $slug" + ./marketing/tweet.sh "$slug" + done diff --git a/.gitignore b/.gitignore index 0c376ea..a15757a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ public/ .DS_Store +.envrc diff --git a/Dockerfile b/Dockerfile index 048cbc6..0b35513 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM golang:1.25 AS builder -RUN go install github.com/gopherguides/hype/cmd/hype@main +RUN go install github.com/gopherguides/hype/cmd/hype@v0.8.0 FROM golang:1.25 COPY --from=builder /go/bin/hype /usr/local/bin/hype diff --git a/Makefile b/Makefile index 53f3c9f..8e8ac74 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build serve dev clean +.PHONY: build serve dev clean tweet tweet-dry build: hype blog build @@ -17,3 +17,9 @@ docker-build: docker-run: docker run -p 3000:3000 hypemd-dev + +tweet: + @./marketing/tweet.sh $(SLUG) + +tweet-dry: + @./marketing/tweet.sh --dry-run $(SLUG) diff --git a/config.yaml b/config.yaml index 57150b0..eb828e1 100644 --- a/config.yaml +++ b/config.yaml @@ -4,7 +4,7 @@ baseURL: "https://hypemd.dev" author: name: "Gopher Guides" email: "" - twitter: "" + twitter: "@hype_markdown" theme: "developer" highlight: style: "monokai" diff --git a/content/ai-authoring-workflow/module.md b/content/ai-authoring-workflow/module.md index 38c4b75..f7e20b8 100644 --- a/content/ai-authoring-workflow/module.md +++ b/content/ai-authoring-workflow/module.md @@ -4,8 +4,10 @@ slug: ai-authoring-workflow published: 03/15/2026 author: Cory LaNou +author_twitter: @corylanou seo_description: Learn how to combine AI coding assistants like Claude with Hype's build-time validation to write technical documentation faster while keeping every code example correct. tags: tutorial, ai, authoring, workflow, claude, hype +tweet: AI can write docs fast, but how do you trust the code examples? Combine Claude with Hype's build-time validation — every snippet is verified before publish.
AI assistants are transforming how we write code. But when it comes to technical documentation, there's a trust problem: AI can generate plausible-looking code examples that don't actually work. Hype solves this by validating everything at build time — making AI-generated content trustworthy through automated verification. diff --git a/content/deploying-with-docker/module.md b/content/deploying-with-docker/module.md index 7616c1f..7aac676 100644 --- a/content/deploying-with-docker/module.md +++ b/content/deploying-with-docker/module.md @@ -4,8 +4,10 @@ slug: deploying-with-docker published: 03/15/2026 author: Cory LaNou +author_twitter: @corylanou seo_description: Deploy a Hype-powered blog site with Docker. Covers Dockerfile setup, Dokploy, Heroku, and generic VPS deployment with Docker Compose. tags: tutorial, docker, deployment, blog, hype +tweet: Deploy a Hype-powered blog with Docker — from Dockerfile to production. Covers Dokploy, Heroku, and Docker Compose setups. Hype builds and serves your blog in a single binary. That makes it a natural fit for Docker — one container that builds your site from source and serves it, with no external web server required. diff --git a/content/engineering-handbook/module.md b/content/engineering-handbook/module.md index 58ede42..0644ed3 100644 --- a/content/engineering-handbook/module.md +++ b/content/engineering-handbook/module.md @@ -4,8 +4,10 @@ slug: engineering-handbook published: 03/15/2026 author: Cory LaNou +author_twitter: @corylanou seo_description: Use Hype to build an internal engineering handbook that stays in sync with your codebase. Executable examples, automated validation, and single-source documentation for your team. tags: tutorial, engineering, handbook, automation, hype +tweet: Your engineering handbook is probably wrong. Not because it was written badly — the code just changed. Here's how to make docs a build artifact with Hype. Every engineering team has internal documentation. Style guides, onboarding docs, architecture decision records, runbook procedures. And almost universally, that documentation is wrong. Not because someone wrote it badly, but because the code changed and nobody updated the docs. diff --git a/content/getting-started/module.md b/content/getting-started/module.md index 624b2bd..edf894e 100644 --- a/content/getting-started/module.md +++ b/content/getting-started/module.md @@ -4,8 +4,10 @@ slug: getting-started published: 03/15/2026 author: Gopher Guides +author_twitter: @hype_markdown seo_description: Learn how to install Hype, create your first dynamic Markdown document, and execute code blocks at build time. tags: tutorial, getting-started, hype +tweet: What if your Markdown could execute code and catch errors before you publish? Get started with Hype — dynamic Markdown for Go developers. Hype is a content engine that makes Markdown dynamic. You can execute code, include files, and validate everything at build time. This guide walks you through installation and your first hype document. diff --git a/content/release-notes-pipeline/module.md b/content/release-notes-pipeline/module.md index 1dc6e69..33437f1 100644 --- a/content/release-notes-pipeline/module.md +++ b/content/release-notes-pipeline/module.md @@ -4,8 +4,10 @@ slug: release-notes-pipeline published: 03/15/2026 author: Cory LaNou +author_twitter: @corylanou seo_description: Build a release notes pipeline using Hype with executable code snippets, automated validation, and version-aware documentation that ships with every release. tags: tutorial, release-notes, ci-cd, pipeline, hype +tweet: Bad release notes erode trust. Build a pipeline where every code example compiles, every command runs, and every API ref is verified at build time. Release notes are the first thing users read after updating. Bad release notes — outdated examples, broken migration commands, incorrect API signatures — erode trust and generate support tickets. Hype lets you build release notes where every code example compiles, every command runs, and every API reference reflects the actual release. diff --git a/content/single-source-docs/module.md b/content/single-source-docs/module.md index 0d1de4b..ec74c16 100644 --- a/content/single-source-docs/module.md +++ b/content/single-source-docs/module.md @@ -4,8 +4,10 @@ slug: single-source-docs published: 03/15/2026 author: Cory LaNou +author_twitter: @corylanou seo_description: Learn how to use Hype to maintain a single Markdown source that generates both your GitHub README and website documentation, keeping them permanently in sync. tags: tutorial, docs, workflow, hype +tweet: Your README says one thing, your docs site says another. Write it once with Hype and generate both from a single source. Every open source project has the same problem: documentation lives in too many places. Your README says one thing, your docs site says another, and the install instructions in your wiki are three versions behind. You end up maintaining the same content in multiple locations, and they inevitably drift apart. diff --git a/content/training-materials/module.md b/content/training-materials/module.md index 926ba01..ccb3306 100644 --- a/content/training-materials/module.md +++ b/content/training-materials/module.md @@ -4,8 +4,10 @@ slug: training-materials published: 03/15/2026 author: Cory LaNou +author_twitter: @corylanou seo_description: Learn how to use Hype to build reusable training and course materials with executable code examples, file includes, and modular content that stays in sync with your codebase. tags: tutorial, training, education, includes, hype +tweet: Training materials go stale the moment code changes. Use Hype to build courses with executable examples that stay in sync with your codebase. If you've ever written training materials for a programming course, you know the pain: code examples go stale, exercises drift out of sync with the slides, and updating one module means checking every other module that references it. Hype was built to solve exactly this problem. diff --git a/marketing/SETUP.md b/marketing/SETUP.md new file mode 100644 index 0000000..7d03757 --- /dev/null +++ b/marketing/SETUP.md @@ -0,0 +1,91 @@ +# X/Twitter API Setup + +## 1. Create an X Developer Account + +1. Go to https://developer.x.com and sign in with the @hype_markdown account +2. Sign up for the **Free** tier (1,500 tweets/month, write-only) +3. Create a new **Project** and **App** + +## 2. Configure App Permissions + +1. In your app settings, go to **User authentication settings** +2. Set app permissions to **Read and Write** +3. After changing permissions, **regenerate** your Access Token and Access Token Secret (old tokens won't have the new permissions) + +## 3. Generate Credentials + +From your app's **Keys and Tokens** page, you need 4 values: + +| Credential | Environment Variable | +|-----------|---------------------| +| API Key (Consumer Key) | `X_API_KEY` | +| API Secret (Consumer Secret) | `X_API_SECRET` | +| Access Token | `X_ACCESS_TOKEN` | +| Access Token Secret | `X_ACCESS_TOKEN_SECRET` | + +## 4. Local Setup + +Copy the credentials into `.envrc` at the project root: + +```bash +export X_API_KEY="your-api-key" +export X_API_SECRET="your-api-secret" +export X_ACCESS_TOKEN="your-access-token" +export X_ACCESS_TOKEN_SECRET="your-access-token-secret" +``` + +Then activate with direnv: + +```bash +direnv allow +``` + +Test with a dry run: + +```bash +make tweet-dry SLUG=getting-started +``` + +## 5. CI Setup (GitHub Actions) + +Set the repository secrets using the `gh` CLI: + +```bash +gh secret set X_API_KEY --body "your-api-key" +gh secret set X_API_SECRET --body "your-api-secret" +gh secret set X_ACCESS_TOKEN --body "your-access-token" +gh secret set X_ACCESS_TOKEN_SECRET --body "your-access-token-secret" +``` + +Once set, the `tweet.yml` workflow will automatically tweet when blog posts with a `tweet` field are merged to main. + +## 6. Manual Tweeting + +To tweet an existing post manually: + +```bash +make tweet SLUG=getting-started +``` + +To preview without posting: + +```bash +make tweet-dry SLUG=getting-started +``` + +## Adding Tweets to Blog Posts + +Add a `tweet` field to the `
` frontmatter block in any blog post: + +```markdown +
+slug: my-article +published: 03/16/2026 +author: Gopher Guides +seo_description: Full SEO description here +tags: tutorial, hype +tweet: Short punchy text for X (max ~250 chars, URL is appended automatically) +
+``` + +The script automatically appends the post URL, so keep the tweet text under ~250 characters. diff --git a/marketing/tweet.sh b/marketing/tweet.sh new file mode 100755 index 0000000..40f4859 --- /dev/null +++ b/marketing/tweet.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +SITE_URL="https://hypemd.dev" +API_URL="https://api.x.com/2/tweets" + +usage() { + echo "Usage: $0 [--dry-run] " + echo "" + echo "Post a tweet for a blog post using the 'tweet' field from its frontmatter." + echo "" + echo "Options:" + echo " --dry-run Preview the tweet without posting" + echo "" + echo "Examples:" + echo " $0 getting-started" + echo " $0 --dry-run ai-authoring-workflow" + echo "" + echo "Environment variables (required unless --dry-run):" + echo " X_API_KEY Consumer API key" + echo " X_API_SECRET Consumer API secret" + echo " X_ACCESS_TOKEN Access token" + echo " X_ACCESS_TOKEN_SECRET Access token secret" + exit 1 +} + +DRY_RUN=false +SLUG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + -h|--help) usage ;; + -*) echo "Unknown option: $1" >&2; usage ;; + *) SLUG="$1"; shift ;; + esac +done + +if [[ -z "$SLUG" ]]; then + usage +fi + +FILE="content/${SLUG}/module.md" +if [[ ! -f "$FILE" ]]; then + echo "Error: $FILE not found" >&2 + exit 1 +fi + +details=$(awk '/
/{found=1; next} /<\/details>/{if(found) exit} found{print}' "$FILE") +slug=$(echo "$details" | grep '^slug:' | head -1 | sed 's/^slug: *//') +tweet_text=$(echo "$details" | grep '^tweet:' | head -1 | sed 's/^tweet: *//') + +if [[ -z "$tweet_text" ]]; then + echo "Error: no 'tweet' field found in $FILE frontmatter" >&2 + exit 1 +fi + +post_url="${SITE_URL}/${slug}/" +full_tweet="${tweet_text} ${post_url}" + +char_count=${#full_tweet} +if [[ $char_count -gt 280 ]]; then + echo "WARNING: Tweet is ${char_count} chars (limit 280). URLs count as 23 chars on X, so actual count may differ." >&2 +fi + +echo "=== Tweet Preview ===" +echo "$full_tweet" +echo "" +echo "Characters: ${char_count} (URLs count as 23 on X)" +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "[Dry run — not posted]" + exit 0 +fi + +for var in X_API_KEY X_API_SECRET X_ACCESS_TOKEN X_ACCESS_TOKEN_SECRET; do + if [[ -z "${!var:-}" ]]; then + echo "Error: $var is not set. See marketing/SETUP.md for instructions." >&2 + exit 1 + fi +done + +python3 - "$full_tweet" << 'PYEOF' +import urllib.parse, hashlib, hmac, base64, time, os, json, secrets, sys, subprocess + +tweet = sys.argv[1] +api_key = os.environ["X_API_KEY"] +api_secret = os.environ["X_API_SECRET"] +access_token = os.environ["X_ACCESS_TOKEN"] +access_token_secret = os.environ["X_ACCESS_TOKEN_SECRET"] + +url = "https://api.x.com/2/tweets" + +oauth_nonce = secrets.token_hex(16) +oauth_timestamp = str(int(time.time())) + +params = { + "oauth_consumer_key": api_key, + "oauth_nonce": oauth_nonce, + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": oauth_timestamp, + "oauth_token": access_token, + "oauth_version": "1.0", +} + +param_string = "&".join( + f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(v, safe='')}" + for k, v in sorted(params.items()) +) +signature_base = f"POST&{urllib.parse.quote(url, safe='')}&{urllib.parse.quote(param_string, safe='')}" +signing_key = f"{urllib.parse.quote(api_secret, safe='')}&{urllib.parse.quote(access_token_secret, safe='')}" +signature = base64.b64encode( + hmac.new(signing_key.encode(), signature_base.encode(), hashlib.sha1).digest() +).decode() + +auth_header = ( + f'OAuth oauth_consumer_key="{urllib.parse.quote(api_key, safe="")}", ' + f'oauth_nonce="{urllib.parse.quote(oauth_nonce, safe="")}", ' + f'oauth_signature="{urllib.parse.quote(signature, safe="")}", ' + f'oauth_signature_method="HMAC-SHA1", ' + f'oauth_timestamp="{oauth_timestamp}", ' + f'oauth_token="{urllib.parse.quote(access_token, safe="")}", ' + f'oauth_version="1.0"' +) + +result = subprocess.run( + ["curl", "-s", "-w", "\n%{http_code}", "-X", "POST", url, + "-H", f"Authorization: {auth_header}", + "-H", "Content-Type: application/json", + "-d", json.dumps({"text": tweet})], + capture_output=True, text=True +) + +output = result.stdout.strip() +http_code = output.split("\n")[-1] +body = "\n".join(output.split("\n")[:-1]) + +if http_code == "201": + data = json.loads(body) + tweet_id = data["data"]["id"] + print("Tweet posted successfully!") + print(f"https://x.com/hype_markdown/status/{tweet_id}") +else: + print(f"Error posting tweet (HTTP {http_code}):", file=sys.stderr) + print(body, file=sys.stderr) + sys.exit(1) +PYEOF