* ci(landing): split landing deploy into staging gate + manual production
A merge to `main` previously published the landing page straight to
production (open-design.ai) via `landing-page-deploy`. There was no
buffer to review the rendered site, so a bad merge was live instantly.
Split deploys across two Cloudflare Pages projects so production is only
ever reached by an explicit human action:
- `landing-page-staging` (push to main) -> staging project
`open-design-landing-staging` -> staging.open-design.ai.
- `landing-page-production` (manual workflow_dispatch only) -> production
project `open-design-landing` -> open-design.ai. Only this workflow
names the production project; gate it with required reviewers on the
`production` GitHub environment.
- `landing-page-ci` now also deploys a per-PR preview into the staging
project (`--branch=pr-<n>`) for same-repo branches and comments the URL.
Fork PRs (no secrets / read-only token) skip the deploy and keep just
the build validation. Path filters already scope this to landing edits.
Decouple search-engine indexing from staging:
- `blog-indexing-on-deploy` now triggers on `landing-page-production`
(not every main push), so the test environment is never submitted to
Google/IndexNow.
- It diffs from a new `blog-indexed-prod` tag (the last indexed prod
commit) instead of `HEAD^`, and force-advances the tag after a
successful run, so a manual promotion bundling several merged posts
indexes all of them rather than only the last commit.
Staging and PR-preview builds drop `PUBLIC_GA_MEASUREMENT_ID` so test
traffic does not pollute the production GA property.
* ci(landing): keep staging + PR previews out of the search index
staging.open-design.ai mirrors production and is exposed via cert
transparency logs, so search engines can discover it. Indexing the
mirror competes with open-design.ai for the same content.
Emit `<meta name="robots" content="noindex, nofollow">` whenever
OD_LANDING_NOINDEX=1, and set that flag on the staging and PR-preview
builds (production leaves it unset and stays indexable). noindex is
used rather than a robots.txt Disallow so crawlers can still fetch the
page and read both the tag and the canonical, which already points at
the production origin.
* fix(landing): make staging noindex actually take effect
The previous commit read `process.env.OD_LANDING_NOINDEX` directly in
`seo-head.astro`, but `.astro` frontmatter is transformed by Vite and
does not see process.env, so the meta never rendered. Two fixes:
- Inject the flag as the compile-time constant `__OD_LANDING_NOINDEX__`
via `vite.define` in astro.config.ts (config runs in Node and can read
process.env); SeoHead consumes that constant.
- The homepage (`index.astro`) and `og.astro` build their own <head> and
never use SeoHead, so a per-component meta can miss pages. Add an
`astro:build:done` integration that appends a catch-all
`/* X-Robots-Tag: noindex, nofollow` to the Cloudflare Pages `_headers`
on staging/preview builds, covering every response (homepage, assets,
any custom-head page) at the HTTP layer. Production builds leave
`_headers` untouched.
Verified: build with OD_LANDING_NOINDEX=1 emits the _headers block and
the SeoHead <meta>; build without the flag emits neither; astro check
clean.
* fix(landing): address review — pin prod checkout to main, defer index pointer
Two blockers from review:
- landing-page-production: workflow_dispatch can be launched from any ref
via the Actions "Use workflow from" dropdown, so an operator could ship
an arbitrary branch to open-design.ai. Pin the checkout to `ref: main`
so the deployed artifact always equals reviewed main.
- blog-indexing-on-deploy: the `blog-indexed-prod` pointer was advanced
right after sitemap submission, before Inspect / Search Analytics /
Render status / Open status PR. A failure in any of those still moved
the pointer, so the next production run skipped those posts. Move the
advance to the very end, gated on `success()`, so a failure leaves the
tag in place and the range is re-processed next run (submissions are
idempotent).
* fix(landing): gate production promotion to the main ref only
Follow-up to the production-path review note: pinning checkout to main
fixed the deployed content, but the workflow was still dispatchable from
any ref, which records a non-main production run and would dodge
blog-indexing's `workflow_run` `branches: [main]` filter. Gate the whole
job on `github.ref == 'refs/heads/main'` so a dispatch from any other
branch/tag is skipped outright.
* feat(landing-page): plugin detail page interactive preview + share dialog
The new `/plugins/<manifest-id>/` detail page that shipped in #2926
landed without the two affordances PR #2679 added to the legacy
`/skills/<slug>/` and `/templates/<slug>/` pages: a click-to-expand
iframe of the live artifact, and a share dialog with brand-keyword
copy plus four-channel jump buttons (X / LinkedIn / Reddit /
Facebook). This restores both, sourced from the bundled-plugin
manifest under `plugins/_official/<bucket>/<slug>/open-design.json`.
## Interactive preview
Three preview-type behaviours, gated on `od.preview.type`:
- `video` (Cloudflare Stream URLs already in the manifest) —
inline `<video controls poster=...>` with the playable MP4 as
`<source>`. Detail-page row is unchanged from #2926; controls
double as the open-full affordance.
- `html` (a local `example.html` referenced by `od.preview.entry`,
only the `examples/` bucket today) — `<details>` toggle wraps the
poster image as the summary; clicking opens a sandboxed
`<iframe>` that loads the entry HTML lazily, with an
"Open in new tab ↗" pill in the frame's top-right corner so the
artifact can be inspected at full screen.
- `image` or no entry — static `<img>` (existing behaviour).
`copy-example-html.ts` is extended to mirror the local entry and any
`./assets/...` siblings to `out/plugins/<manifest-id>/<entry>` so the
iframe URL resolves on Cloudflare Pages instead of SPA-falling-back to
the homepage. The four examples carrying sibling-asset references
(flowai-live-dashboard-template, trading-analysis-dashboard-template,
open-design-landing, open-design-landing-deck) all render in-place.
## Share dialog
Same `<dialog data-share-dialog>` markup the legacy detail pages use,
so the global click handlers in `header-enhancer.astro`
(`data-share-open` / `data-share-copy` / `data-copy-link`) wire up
the open / copy actions automatically — no extra client bundle. Four
platform jumps (X / LinkedIn / Reddit / Facebook) plus a Copy-text /
Copy-link pair, with a single English template for now (the new
`/plugins/...` routes only generate English pages; localisation can
land alongside the i18n catch-all follow-up).
## Bundled in
- The `copy-example-html.ts` sibling-assets fix from open PR #2880.
Without it the existing `/skills/<slug>/` iframe still 404s on
Cloudflare Pages for after-hours-editorial-template and the four
others; bundling it here means the same script handles both
sources in one pass and sidesteps two PRs touching identical
helper code.
* fix(plugins): remove dangling preview.entry from example-hyperframes
The hyperframes example folder ships a SKILL.md (it's an instruction
manual for using the HyperFrames HTML format) but no runnable
`example.html`. The manifest still claimed `preview.type: html` /
`preview.entry: ./example.html`, which made the marketing site try
to iframe a non-existent file and forced the preview pipeline into
its `Path 3` fallback card — leaving the catalog row visually
inconsistent with the eleven sibling `video-template-hyperframes-*`
plugins that have real Cloudflare-Stream poster URLs.
Drop the preview block entirely so the manifest stops promising a
demo it can't deliver. The landing-page detail row continues to
render the typographic fallback card (sourced from title /
description / mode), which is now the honest representation:
"this is an instruction skill, not a renderable template".
* fix(landing-page): address PR #2958 review feedback on plugin preview pipeline
Two blocking issues called out in code review:
1) `bundled-plugins.ts` exposed `previewEntryUrl` for every manifest
that declared `preview.type: "html"`, even when the entry file
wasn't shipped. Several first-party manifests fall in this state
(example-design-brief's `./brief-preview.html`, example-x-research,
example-pptx-html-fidelity-audit, example-hatch-pet,
example-last30days, example-guizang-ppt, example-replit-deck,
example-live-artifact, example-html-ppt, example-dcf-valuation).
The detail page then rendered a click-to-expand iframe and popout
link to a file that copy-example-html.ts had skipped, so the
iframe URL SPA-fell-back to the homepage on Cloudflare Pages.
`entryRelativeUrl()` now `existsSync()`-checks the resolved local
path before returning a URL. When the file's missing the detail
page falls through to the static thumbnail branch, exactly like
plugins that ship no preview entry at all.
2) `copy-example-html.ts` recognised only `(src|href|poster)="./assets/..."`
and then bulk-copied the entry's sibling `assets/` folder, so it
missed two real ref shapes: bare-relative (`href="assets/styles.css"`,
`src="assets/deck-stage.js"` under example-html-ppt-zhangzara-pin-and-paper)
and cross-folder (`src="../open-design-landing/assets/hero.png"`
under example-open-design-landing-deck).
Replaced the heuristic with a generic walker that:
- Parses every relative ref in the entry HTML
(`(src|href|poster|srcset|data-src)=` plus `url(...)`), splitting
srcset on whitespace/commas so multi-URL attrs are honoured.
- Resolves each ref against `dirname(entrypointSrc)` for the source
and against `dirname(iframeAbsPath)` for the destination —
identical to how a browser resolves the same ref against the
iframe URL. Files outside the source root or the iframe root
are dropped.
- Recurses into copied HTML / CSS / JS / SVG so multi-step chains
(entry → assets/template.html → assets/fonts/foo.woff) don't
strand intermediate files.
- Tracks visited *destinations* rather than sources, so a single
source that legitimately needs to land at two different out-paths
(same-folder copy at /plugins/example-X/assets/foo.png AND a
cross-folder copy at /plugins/open-design-landing/assets/foo.png
for sibling decks that use `../open-design-landing/assets/foo.png`)
gets both copies.
Verified manually:
- /plugins/example-html-ppt-zhangzara-pin-and-paper/assets/styles.css
and assets/deck-stage.js → 200 (bare-relative)
- /plugins/open-design-landing/assets/hero.png and assets/about.png
→ 200 (cross-folder destination, no manifest-id prefix because
iframe URL `..` collapses the prefix)
- /plugins/example-design-brief/ renders the static thumbnail only,
no click-to-expand iframe (broken entry guard)
- /plugins/example-flowai-live-dashboard-template/assets/template.html
→ 200 (existing same-folder behaviour preserved)
Build now reports `copied 266 entry files + 65 referenced files`,
where the 65 includes both the same-folder `./assets/...` payloads
the previous heuristic captured and the bare-relative + cross-folder
shapes it didn't.
---------
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
* feat(landing): add /share-out redirect for X share button click tracking
Adds a Cloudflare Pages Function at /share-out/:eventId that records each
click of the "Share on X" button surfaced in the contributor card comments
on GitHub, then 302-redirects to the original twitter.com / x.com intent
URL (passed via ?to=, host-allowlisted).
Together with the existing /share/:eventId function this gives us both
sides of the X funnel without an X API key:
- /share-out/:eventId -> GitHub user clicked the X button (funnel step 1)
- /share/:eventId -> someone on X clicked the posted tweet (funnel step 2)
Per-event KV storage is optional (SHARE_OUT_CLICK_EVENTS). When no KV is
bound the function falls back to console.log; aggregate counts are visible
in Cloudflare Pages analytics with no extra setup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: ashley li <ashleyli@ashleydeMacBook-Air-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>