mirror of
https://github.com/hanzoai/flow.git
synced 2026-10-17 01:29:18 +00:00
ci: publish the storybook through our own Sites plane; delete three foreign ones
Three site deploys in this repo published to planes that are not ours. deploy-storybook.yml handed src/frontend/storybook-static to actions/deploy-pages. It is a real static site, so it moves: .hanzo/workflows/storybook.yml builds the export, POSTs /v1/projects/flow-storybook/deploy, syncs the bytes straight to the S3 prefix cloud names in the 202, then flips the deployment live — and marks it error if the run dies after enqueueing, so cloud never records a deployment as "queued" forever. Modelled on hanzoai/hips. It runs on hanzo-build-linux-amd64, a label the forge runners actually advertise; a label nothing answers does not fail, it queues silently until the 24h timeout. This is a SEPARATE file from .hanzo/workflows/deploy.yml on purpose. That one builds and pushes ghcr.io/hanzoai/flow — the App the operator runs. An App is a container the cluster executes, a Site is bytes S3 serves; folding a docs publish into an image build would couple it to a deployable release. deploy_gh-pages.yml pushed docs/ to a gh-pages branch via peaceiris/actions-gh-pages, and deploy-docs-draft.yml published per-PR docs previews to an AWS S3 bucket behind CloudFront on upstream's account (DOCS_AWS_ACCESS_KEY_ID, DOCS_DRAFT_CLOUD_FRONT_DISTRIBUTION_ID). Both are gone with no native replacement, because this repo is not where these docs live: hanzoai/docs carries them at apps/flow, and standing a second site up here would be a second source of truth for one set of pages. docs/static/CNAME (docs.flow.org) and docs/static/.nojekyll go with them — Pages artifacts that mean nothing to S3. The docs tree also shipped three third-party trackers: a Google gtag (G-SLQFLQ3KPT), the consent-mode + TrustArc scripts feeding it, and a Segment plugin reading SEGMENT_PUBLIC_WRITE_KEY. All removed, along with @docusaurus/plugin-google-tag-manager, @mendable/search and docs/src/plugins/segment. Telemetry goes to our own bus or nowhere. Analytics on the storybook is the ONE hosted tag (cloud apps/analytics/tag.go) posting to POST /v1/event. Its key is read from the project this run deploys, so pk- rotation is one server-side change rather than five repo edits, and no key means no tag: a keyless beacon is accepted 200 into $public, a tenant this org cannot read. Every other workflow stays. They are release, image-build, lint and test lanes, not deploy planes. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,270 +0,0 @@
|
||||
name: Pull Request Docs Draft
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-docs-draft.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: "! github.event.pull_request.head.repo.fork"
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: ./docs/package-lock.json
|
||||
|
||||
- name: Validate Branch Names
|
||||
env:
|
||||
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
# Check if branch names contain invalid characters. Only alphanumeric, _, -, ., and / are allowed.
|
||||
validate_branch_name() {
|
||||
local branch_name="$1"
|
||||
if [[ ! "$branch_name" =~ ^[a-zA-Z0-9/_\.-]+$ ]]; then
|
||||
echo "Error: Branch name contains invalid characters. Only alphanumeric, _, -, ., and / are allowed."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_branch_name "$BRANCH_REF"
|
||||
|
||||
- name: Extract Branch Names
|
||||
id: extract_branch
|
||||
env:
|
||||
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
# Extract and transform branch names
|
||||
extract_branch() {
|
||||
local input_branch="$1"
|
||||
# Check if input_branch starts with "refs/heads/"
|
||||
if [[ "$input_branch" == refs/heads/* ]]; then
|
||||
# Remove "refs/heads/" prefix safely using parameter expansion
|
||||
branch_name="${input_branch#refs/heads/}"
|
||||
echo "$branch_name"
|
||||
else
|
||||
echo "$input_branch"
|
||||
fi
|
||||
}
|
||||
|
||||
# Transform branch names in form of `refs/heads/main` to `main`
|
||||
draft_branch=$(extract_branch "$BRANCH_REF")
|
||||
|
||||
# Replace / with - in the draft branch name to use as a directory name
|
||||
draft_directory=$(echo "$draft_branch" | tr / -)
|
||||
|
||||
# Safe echo to $GITHUB_OUTPUT
|
||||
{
|
||||
echo "draft_branch=$draft_branch"
|
||||
echo "draft_directory=$draft_directory"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set Draft URL
|
||||
id: draft_url
|
||||
if: success()
|
||||
run: |
|
||||
echo "url=${{ vars.DOCS_DRAFT_BASE_URL }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/index.html" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd docs && npm install
|
||||
|
||||
- name: Build website
|
||||
if: success()
|
||||
run: |
|
||||
set -o pipefail
|
||||
cd docs
|
||||
npm run build |& tee $GITHUB_WORKSPACE/build.log
|
||||
env:
|
||||
BASE_URL: /flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}
|
||||
FORCE_COLOR: 0 # Disable color output
|
||||
SEGMENT_PUBLIC_WRITE_KEY: ${{ vars.DOCS_DRAFT_IBM_SEGMENT_PUBLIC_WRITE_KEY }}
|
||||
|
||||
- name: Check Build Result
|
||||
id: buildLogFail
|
||||
if: failure()
|
||||
run: |
|
||||
MULTILINE_LOG=$(cat $GITHUB_WORKSPACE/build.log)
|
||||
echo "BUILD_FAILURE<<EOF" >> $GITHUB_ENV
|
||||
echo $MULTILINE_LOG >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Hide Previous Build Comments
|
||||
if: ${{ github.event.pull_request.number && (success() || failure()) }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Get all comments on the PR that match our build comments
|
||||
comments=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
|
||||
--jq '.[] | select(.body | test("Build failure! :x:|Build successful! :white_check_mark:")) | .node_id')
|
||||
|
||||
# Minimize each matching comment using GraphQL API
|
||||
if [[ -n "$comments" ]]; then
|
||||
echo "Found previous build comments to hide"
|
||||
while IFS= read -r comment_id; do
|
||||
if [[ -n "$comment_id" ]]; then
|
||||
echo "Minimizing comment: $comment_id"
|
||||
gh api graphql \
|
||||
--field id="$comment_id" \
|
||||
--field classifier="OUTDATED" \
|
||||
--raw-field query='
|
||||
mutation($id: ID!, $classifier: ReportedContentClassifiers!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: $classifier }) {
|
||||
minimizedComment {
|
||||
isMinimized
|
||||
}
|
||||
}
|
||||
}' || echo "Failed to minimize comment $comment_id, continuing..."
|
||||
echo
|
||||
fi
|
||||
done <<< "$comments"
|
||||
else
|
||||
echo "No previous build comments found to hide"
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Assemble Build Success Comment
|
||||
if: success()
|
||||
run: |
|
||||
build_success_comment="Build successful! :white_check_mark:"
|
||||
build_success_comment+="\nDeploying docs draft."
|
||||
|
||||
echo "BUILD_SUCCESS_COMMENT<<EOF" >> $GITHUB_ENV
|
||||
echo -e "$build_success_comment" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Build Success Comment
|
||||
if: success()
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: "${{ env.BUILD_SUCCESS_COMMENT }}"
|
||||
reactions: rocket
|
||||
|
||||
- name: Create Build Failure Comment
|
||||
if: failure()
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
Build failure! :x:
|
||||
> ${{ env.BUILD_FAILURE }}
|
||||
reactions: confused
|
||||
|
||||
- name: Find Comment
|
||||
id: fc
|
||||
if: success()
|
||||
uses: peter-evans/find-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: Build successful!
|
||||
direction: last
|
||||
|
||||
- name: Configure AWS CLI
|
||||
if: success()
|
||||
run: |
|
||||
aws configure set aws_access_key_id ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }}
|
||||
aws configure set aws_secret_access_key ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }}
|
||||
aws configure set region us-west-2
|
||||
|
||||
- name: Check for New Assets
|
||||
run: |
|
||||
set -o pipefail
|
||||
echo "Checking for new assets." |& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
echo "aws s3 sync docs/build/assets/ s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/assets/ --size-only --dryrun --no-progress" | tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
aws s3 sync docs/build/assets/ "s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/assets/" --size-only --dryrun --no-progress | tee $GITHUB_WORKSPACE/assets.log
|
||||
|
||||
- name: Determine Standard or Full Publish
|
||||
id: check_full_publish
|
||||
run: |
|
||||
# Determine if a full publish is required because of new assets.
|
||||
if grep -qE '(upload:|delete:)' "$GITHUB_WORKSPACE/assets.log"; then
|
||||
echo "New assets. Perform full publish: true" | tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
echo "perform_full_publish=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No new assets. Perform full publish: false" | tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
echo "perform_full_publish=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Deploy to S3
|
||||
if: success()
|
||||
run: |
|
||||
set -o pipefail
|
||||
cd docs
|
||||
mkdir flow-drafts
|
||||
mv build flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}
|
||||
cd flow-drafts
|
||||
|
||||
# Records the repository that originally triggered the build so we can post back
|
||||
# comments upon clean up of a stale draft if it still has an open pull request.
|
||||
echo "${{ github.event.repository.full_name }}" > ${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository
|
||||
|
||||
s3_params=(
|
||||
# Hide upload progress for a cleaner sync log
|
||||
--no-progress
|
||||
--delete
|
||||
--exclude "*"
|
||||
--include "${{ steps.extract_branch.outputs.draft_directory }}/*"
|
||||
)
|
||||
|
||||
if [[ "${{ steps.check_full_publish.outputs.perform_full_publish }}" == "false" ]]; then
|
||||
s3_params+=(--size-only)
|
||||
fi
|
||||
|
||||
echo "Deploying draft to S3." |& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
echo "aws s3 sync . s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts ${s3_params[@]}" |& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
aws s3 sync . "s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts" "${s3_params[@]}" |& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
|
||||
# Update .github_source_repository file metadata to mark last modified time of the draft.
|
||||
# This will allow us to later determine if a draft is stale and needs to be cleaned up.
|
||||
echo "Marking last modified time of the draft." |& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
echo "aws s3 cp --metadata '{\"touched\": \"now\"}' \
|
||||
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \
|
||||
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository" \
|
||||
|& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
|
||||
aws s3 cp --metadata '{ "touched": "now" }' \
|
||||
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \
|
||||
s3://${{ vars.DOCS_DRAFT_S3_BUCKET_NAME }}/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \
|
||||
|& tee -a $GITHUB_WORKSPACE/deploy.log
|
||||
|
||||
- name: Invalidate CloudFront Cache
|
||||
if: success()
|
||||
run: |
|
||||
invalidation_batch="{ \"Paths\": { \"Quantity\": 1, \"Items\": [\"/flow-drafts/${{ steps.extract_branch.outputs.draft_directory }}/*\"] }, \"CallerReference\": \"flow-docs-draft-files-$(date +%s)\" }"
|
||||
|
||||
echo $invalidation_batch | jq . |& tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
echo "Creating invalidation." |& tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
invalidation_id=$(aws cloudfront create-invalidation --distribution-id "${{ vars.DOCS_DRAFT_CLOUD_FRONT_DISTRIBUTION_ID }}" --invalidation-batch "$invalidation_batch" --query 'Invalidation.Id' --output text |& tee -a "$GITHUB_WORKSPACE/deploy.log")
|
||||
|
||||
echo "Awaiting invalidation." |& tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
aws cloudfront wait invalidation-completed --distribution-id "${{ vars.DOCS_DRAFT_CLOUD_FRONT_DISTRIBUTION_ID }}" --id "$invalidation_id" |& tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
echo "Invalidation complete." |& tee -a "$GITHUB_WORKSPACE/deploy.log"
|
||||
|
||||
- name: Update Comment
|
||||
if: ${{ steps.fc.outputs.comment-id != '' }}
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
body: |
|
||||
Deploy successful! [View draft](${{ steps.draft_url.outputs.url }})
|
||||
reactions: hooray
|
||||
|
||||
- name: Upload Deploy Log
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: deploy.log
|
||||
path: ${{ github.workspace }}/deploy.log
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Deploy Storybook to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/frontend/**/*.stories.*'
|
||||
- 'src/frontend/.storybook/**'
|
||||
- 'src/frontend/package.json'
|
||||
- '.github/workflows/deploy-storybook.yml'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Storybook
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend && npm ci
|
||||
|
||||
- name: Build Storybook
|
||||
run: cd src/frontend && npm run build-storybook
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: src/frontend/storybook-static
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to deploy docs from"
|
||||
required: false
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.branch }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: ./docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd docs && npm install
|
||||
- name: Build website
|
||||
run: cd docs && npm run build
|
||||
env:
|
||||
SEGMENT_PUBLIC_WRITE_KEY: ${{ vars.DOCS_PROD_IBM_SEGMENT_PUBLIC_WRITE_KEY }}
|
||||
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
publish_dir: ./docs/build
|
||||
# The following lines assign commit authorship to the official
|
||||
# GH-Actions bot for deploys to `gh-pages` branch:
|
||||
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
# The GH actions bot is used by default if you didn't specify the two fields.
|
||||
# You can swap them out with your own user credentials.
|
||||
@@ -0,0 +1,186 @@
|
||||
name: storybook
|
||||
|
||||
# The frontend storybook is a SITE, not an App.
|
||||
#
|
||||
# It used to publish through actions/deploy-pages
|
||||
# (.github/workflows/deploy-storybook.yml). That is a second deploy plane living
|
||||
# outside our own CI/CD, outside the Sites plane, and outside our analytics, so
|
||||
# it is gone.
|
||||
#
|
||||
# npm run build-storybook -> src/frontend/storybook-static
|
||||
# -> POST /v1/projects/flow-storybook/deploy {"source":"git"} (202, queued)
|
||||
# -> aws s3 sync storybook-static s3://<bucket>/<prefix> (bytes skip the API)
|
||||
# -> POST .../deployments/<id>/complete {"status":"live"}
|
||||
#
|
||||
# Modelled on hanzoai/hips .hanzo/workflows/deploy.yml, which is the canonical
|
||||
# shape for this. The bytes go straight to S3 rather than through an artifact
|
||||
# POST because the door has a 16 MiB BodyLimit that fasthttp enforces BEFORE any
|
||||
# handler runs, and a storybook bundle clears that easily. cloud names the bucket
|
||||
# and prefix in the 202 so CI writes exactly where the site is served from.
|
||||
#
|
||||
# THIS IS A SEPARATE FILE FROM .hanzo/workflows/deploy.yml ON PURPOSE. That one
|
||||
# builds and pushes ghcr.io/hanzoai/flow — the App the operator runs. An App is
|
||||
# a container the cluster executes; a Site is bytes S3 serves. They have
|
||||
# different outputs, different triggers and different failure modes, and folding
|
||||
# a storybook export into an image build would couple a docs publish to a
|
||||
# deployable release.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/frontend/**/*.stories.*'
|
||||
- 'src/frontend/.storybook/**'
|
||||
- 'src/frontend/package.json'
|
||||
- 'src/frontend/package-lock.json'
|
||||
- '.hanzo/workflows/storybook.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: storybook-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
site:
|
||||
# NOT ubuntu-latest. These run on the in-cluster act_runner, whose fleet
|
||||
# advertises hanzo-build-linux-amd64. A label no runner answers does not
|
||||
# fail — it queues until the 24h timeout, which is silence, not an error.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: build the static export
|
||||
working-directory: src/frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build-storybook
|
||||
test -s storybook-static/index.html || {
|
||||
echo "::error::storybook produced no storybook-static/index.html"; exit 1; }
|
||||
# A storybook shell renders with zero stories and still writes a
|
||||
# non-empty index.html, so assert the story index has entries rather
|
||||
# than trusting the file's existence.
|
||||
test -s storybook-static/index.json || {
|
||||
echo "::error::storybook wrote no index.json — nothing was indexed"; exit 1; }
|
||||
echo "exported $(find storybook-static -type f | wc -l) files"
|
||||
|
||||
# The ONE analytics wire: cloud's hosted tag (cloud apps/analytics/tag.go),
|
||||
# which posts the canonical batch to POST /v1/event. There is no Segment,
|
||||
# no Google Tag Manager, no second tracker, and no vendored copy of the
|
||||
# tag — it is served from the origin that eats it so a page allowlists ONE
|
||||
# host.
|
||||
#
|
||||
# The key is the project's OWN publishable key, read from the project this
|
||||
# run is about to deploy. It is never a checked-in constant: pk- is
|
||||
# write-only and publishable (same trust class as a Sentry DSN), so it
|
||||
# belongs in the page, but its authority is the project record, which means
|
||||
# rotation is one server-side change and not five repo edits.
|
||||
#
|
||||
# NO KEY => NO TAG, deliberately. A keyless beacon is accepted 200 into
|
||||
# $public, a reserved tenant this org cannot read, so silence is the honest
|
||||
# failure and we pick it over reporting into a tenant nobody reads.
|
||||
#
|
||||
# This is storybook's generated HTML, which no template of ours can reach,
|
||||
# so the tag is stamped into the export. Same step, same shape, in every
|
||||
# doc site in this fleet.
|
||||
- name: wire the analytics tag
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY=$(curl -sS -H "Authorization: Bearer ${DEPLOY_TOKEN}" \
|
||||
"https://api.hanzo.ai/v1/projects/flow-storybook" | jq -r '.key // empty')
|
||||
if [ -z "$KEY" ]; then
|
||||
echo "::warning::project flow-storybook exposes no publishable key — publishing untagged rather than beaconing into \$public"
|
||||
exit 0
|
||||
fi
|
||||
TAG="<script defer src=\"https://api.hanzo.ai/v1/event.js\" data-key=\"${KEY}\" data-product=\"flow-storybook\"></script>"
|
||||
find src/frontend/storybook-static -name '*.html' -print0 \
|
||||
| xargs -0 -r sed -i "s#</head>#${TAG}</head>#"
|
||||
echo "tagged $(grep -rl 'v1/event.js' src/frontend/storybook-static | wc -l) pages"
|
||||
|
||||
- name: enqueue the deployment
|
||||
id: enqueue
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_TOKEN:-}" ]; then
|
||||
echo "::error::HANZO_DEPLOY_TOKEN is unset. It is an org secret on the git.hanzo.ai forge."
|
||||
exit 1
|
||||
fi
|
||||
CODE=$(curl -sS -o /tmp/enq.json -w '%{http_code}' \
|
||||
-X POST "https://api.hanzo.ai/v1/projects/flow-storybook/deploy" \
|
||||
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"source\":\"git\",\"commit\":\"${GITHUB_SHA}\",\"branch\":\"main\"}")
|
||||
# 202 Accepted is the success code — the deployment is queued, not live.
|
||||
if [ "$CODE" != "202" ]; then
|
||||
echo "::error::enqueue returned HTTP $CODE"; head -c 600 /tmp/enq.json; exit 1
|
||||
fi
|
||||
# bucket and prefix come FROM cloud. Never hardcode them: prefix is
|
||||
# sitePrefix(org, slug) server-side, so a local guess drifts the moment
|
||||
# the org or slug changes and the sync lands bytes nobody serves.
|
||||
echo "id=$(jq -r .id /tmp/enq.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "bucket=$(jq -r .bucket /tmp/enq.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "prefix=$(jq -r .prefix /tmp/enq.json)" >> "$GITHUB_OUTPUT"
|
||||
jq -c '{id,version,status,bucket,prefix}' /tmp/enq.json
|
||||
|
||||
- name: sync the export to S3
|
||||
id: sync
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SITES_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SITES_S3_SECRET_ACCESS_KEY }}
|
||||
BUCKET: ${{ steps.enqueue.outputs.bucket }}
|
||||
PREFIX: ${{ steps.enqueue.outputs.prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
|
||||
echo "::error::SITES_S3_* secrets unset — cannot sync. These are the hanzo-sites bucket credentials."
|
||||
exit 1
|
||||
fi
|
||||
# --delete so a removed story actually disappears: without it the
|
||||
# prefix only ever grows and deleted stories keep serving.
|
||||
aws s3 sync src/frontend/storybook-static "s3://${BUCKET}/${PREFIX}" \
|
||||
--endpoint-url https://s3.hanzo.ai --delete --only-show-errors
|
||||
echo "files=$(find src/frontend/storybook-static -type f | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT"
|
||||
echo "bytes=$(du -sk src/frontend/storybook-static | awk '{print $1*1024}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: flip it live
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
|
||||
DEP: ${{ steps.enqueue.outputs.id }}
|
||||
FILES: ${{ steps.sync.outputs.files }}
|
||||
BYTES: ${{ steps.sync.outputs.bytes }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CODE=$(curl -sS -o /tmp/done.json -w '%{http_code}' \
|
||||
-X POST "https://api.hanzo.ai/v1/projects/flow-storybook/deployments/${DEP}/complete" \
|
||||
-H "Authorization: Bearer ${DEPLOY_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"status\":\"live\",\"commit\":\"${GITHUB_SHA}\",\"files\":${FILES},\"bytes\":${BYTES}}")
|
||||
if [ "$CODE" != "200" ]; then
|
||||
echo "::error::complete returned HTTP $CODE"; head -c 600 /tmp/done.json; exit 1
|
||||
fi
|
||||
jq -c '{status,liveUrl,version,files,bytes}' /tmp/done.json
|
||||
|
||||
# A run that dies after the enqueue would leave the deployment "queued" and
|
||||
# the project stuck "building" forever. Report the failure so cloud records
|
||||
# an honest terminal state instead of a lie by omission.
|
||||
- name: report a failed build
|
||||
if: failure() && steps.enqueue.outputs.id != ''
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.HANZO_DEPLOY_TOKEN }}
|
||||
DEP: ${{ steps.enqueue.outputs.id }}
|
||||
run: |
|
||||
curl -sS -X POST "https://api.hanzo.ai/v1/projects/flow-storybook/deployments/${DEP}/complete" \
|
||||
-H "Authorization: Bearer ${DEPLOY_TOKEN}" -H 'Content-Type: application/json' \
|
||||
-d '{"status":"error","message":"CI build or sync failed"}' >/dev/null || true
|
||||
echo "marked deployment ${DEP} as error"
|
||||
@@ -6,8 +6,6 @@ const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||
const { remarkCodeHike } = require("@code-hike/mdx");
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "HanzoFlow Documentation",
|
||||
@@ -39,66 +37,6 @@ const config = {
|
||||
href: "https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&family=Sora:wght@550;600&display=swap",
|
||||
},
|
||||
},
|
||||
...(isProduction
|
||||
? [
|
||||
// Google Consent Mode - Set defaults before Google tags load
|
||||
{
|
||||
tagName: "script",
|
||||
attributes: {},
|
||||
innerHTML: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
|
||||
// Set default consent to denied
|
||||
gtag('consent', 'default', {
|
||||
'ad_storage': 'denied',
|
||||
'ad_user_data': 'denied',
|
||||
'ad_personalization': 'denied',
|
||||
'analytics_storage': 'denied'
|
||||
});
|
||||
`,
|
||||
},
|
||||
// TrustArc Consent Update Listener
|
||||
{
|
||||
tagName: "script",
|
||||
attributes: {},
|
||||
innerHTML: `
|
||||
(function() {
|
||||
function updateGoogleConsent() {
|
||||
if (typeof window.truste !== 'undefined' && window.truste.cma) {
|
||||
var consent = window.truste.cma.callApi('getConsent', window.location.href) || {};
|
||||
|
||||
// Map TrustArc categories to Google consent types
|
||||
// Category 0 = Required, 1 = Functional, 2 = Advertising, 3 = Analytics
|
||||
var hasAdvertising = consent[2] === 1;
|
||||
var hasAnalytics = consent[3] === 1;
|
||||
|
||||
gtag('consent', 'update', {
|
||||
'ad_storage': hasAdvertising ? 'granted' : 'denied',
|
||||
'ad_user_data': hasAdvertising ? 'granted' : 'denied',
|
||||
'ad_personalization': hasAdvertising ? 'granted' : 'denied',
|
||||
'analytics_storage': hasAnalytics ? 'granted' : 'denied'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for consent changes
|
||||
if (window.addEventListener) {
|
||||
window.addEventListener('cm_data_subject_consent_changed', updateGoogleConsent);
|
||||
window.addEventListener('cm_consent_preferences_set', updateGoogleConsent);
|
||||
}
|
||||
|
||||
// Initial check after TrustArc loads
|
||||
if (document.readyState === 'complete') {
|
||||
updateGoogleConsent();
|
||||
} else {
|
||||
window.addEventListener('load', updateGoogleConsent);
|
||||
}
|
||||
})();
|
||||
`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
presets: [
|
||||
@@ -145,9 +83,6 @@ const config = {
|
||||
priority: null,
|
||||
ignorePatterns: [],
|
||||
},
|
||||
gtag: {
|
||||
trackingID: "G-SLQFLQ3KPT",
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: [
|
||||
@@ -200,7 +135,6 @@ const config = {
|
||||
},
|
||||
["docusaurus-node-polyfills", { excludeAliases: ["console"] }],
|
||||
"docusaurus-plugin-image-zoom",
|
||||
["./src/plugins/segment", { segmentPublicWriteKey: process.env.SEGMENT_PUBLIC_WRITE_KEY, allowedInDev: true }],
|
||||
[
|
||||
"@docusaurus/plugin-client-redirects",
|
||||
{
|
||||
@@ -524,7 +458,6 @@ const config = {
|
||||
{
|
||||
html: `<div class="footer-links">
|
||||
<span>© ${new Date().getFullYear()} Flow</span>
|
||||
<span id="preferenceCenterContainer"> · <a href="#" onclick="if(typeof window !== 'undefined' && window.truste && window.truste.eu && window.truste.eu.clickListener) { window.truste.eu.clickListener(); } return false;" style="cursor: pointer;">Manage Privacy Choices</a></span>
|
||||
</div>`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
"@code-hike/mdx": "^0.9.0",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.9.2",
|
||||
"@docusaurus/plugin-google-tag-manager": "^3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.45.0",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@mendable/search": "^0.0.206",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-node-polyfills": "^1.0.0",
|
||||
"docusaurus-plugin-image-zoom": "^2.0.0",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Get common properties that should be included in all events
|
||||
*/
|
||||
export function getCommonProperties() {
|
||||
if (typeof window === 'undefined' || !window.digitalData?.commonProperties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
productTitle: window.digitalData.commonProperties.productTitle,
|
||||
productCode: window.digitalData.commonProperties.productCode,
|
||||
productCodeType: window.digitalData.commonProperties.productCodeType,
|
||||
UT30: window.digitalData.commonProperties.UT30,
|
||||
instanceId: window.digitalData.commonProperties.instanceId,
|
||||
subscriptionId: window.digitalData.commonProperties.subscriptionId,
|
||||
productPlanName: window.digitalData.commonProperties.productPlanName,
|
||||
productPlanType: window.digitalData.commonProperties.productPlanType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID for identify calls
|
||||
*/
|
||||
export function getUserId() {
|
||||
if (typeof window === 'undefined' || !window.digitalData?.commonProperties) {
|
||||
return 'IBMid-ANONYMOUS';
|
||||
}
|
||||
|
||||
return window.digitalData.commonProperties.userId || 'IBMid-ANONYMOUS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call identify with required properties
|
||||
* Should be called before any track events
|
||||
*/
|
||||
export function identifyUser() {
|
||||
if (typeof window === 'undefined' || !window.analytics?.identify) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = getUserId();
|
||||
const traits = getCommonProperties();
|
||||
|
||||
window.analytics.identify(userId, traits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced track function that includes common properties
|
||||
*/
|
||||
export function trackEvent(eventName, properties = {}) {
|
||||
if (typeof window === 'undefined' || !window.analytics?.track) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commonProps = getCommonProperties();
|
||||
const mergedProperties = { ...commonProps, ...properties };
|
||||
|
||||
window.analytics.track(eventName, mergedProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced page function that includes common properties
|
||||
*/
|
||||
export function trackPage(name, properties = {}) {
|
||||
if (typeof window === 'undefined' || !window.analytics?.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commonProps = getCommonProperties();
|
||||
const mergedProperties = { ...commonProps, ...properties };
|
||||
|
||||
window.analytics.page(name, mergedProperties);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import { identifyUser, trackPage } from './analytics-helpers';
|
||||
|
||||
/**
|
||||
* Get friendly page name from pathname
|
||||
*/
|
||||
function getFriendlyPageName(pathname) {
|
||||
// Remove leading/trailing slashes
|
||||
const cleanPath = pathname.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// Handle root/home page
|
||||
if (!cleanPath) {
|
||||
return 'Home';
|
||||
}
|
||||
|
||||
// Split by slash and capitalize each segment
|
||||
const segments = cleanPath.split('/');
|
||||
const formattedSegments = segments.map(segment => {
|
||||
// Replace hyphens with spaces and capitalize
|
||||
return segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
return formattedSegments.join(' > ');
|
||||
}
|
||||
|
||||
// Client module for tracking page views on route changes
|
||||
export function onRouteDidUpdate({location, previousLocation}) {
|
||||
// Only track page views in the browser and when the path actually changes
|
||||
if (
|
||||
ExecutionEnvironment.canUseDOM &&
|
||||
previousLocation &&
|
||||
location.pathname !== previousLocation.pathname &&
|
||||
window.analytics &&
|
||||
window.analytics.page
|
||||
) {
|
||||
// Identify user before tracking page
|
||||
identifyUser();
|
||||
|
||||
// Track page with friendly name
|
||||
const pageName = getFriendlyPageName(location.pathname);
|
||||
trackPage(pageName, {
|
||||
path: location.pathname,
|
||||
url: location.href,
|
||||
search: location.search,
|
||||
title: document.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initial page view on load
|
||||
if (ExecutionEnvironment.canUseDOM) {
|
||||
window.addEventListener('load', () => {
|
||||
if (window.analytics && window.analytics.page) {
|
||||
// Identify user on initial page load
|
||||
identifyUser();
|
||||
|
||||
// Track initial page view
|
||||
const pageName = getFriendlyPageName(window.location.pathname);
|
||||
trackPage(pageName, {
|
||||
path: window.location.pathname,
|
||||
url: window.location.href,
|
||||
search: window.location.search,
|
||||
title: document.title,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import { identifyUser, trackEvent } from './analytics-helpers';
|
||||
|
||||
let isDataAttributeTrackingInitialized = false;
|
||||
|
||||
/**
|
||||
* Automatic click tracking using data attributes for Docusaurus
|
||||
*
|
||||
* Usage: Add data-event and other data-* attributes to any clickable element.
|
||||
* The tracker will automatically send events to Segment when clicked.
|
||||
*
|
||||
* Example - UI Interaction:
|
||||
* {
|
||||
* href: "https://github.com/hanzoai/flow",
|
||||
* 'data-event': 'UI Interaction',
|
||||
* 'data-action': 'clicked',
|
||||
* 'data-channel': 'docs',
|
||||
* 'data-element-id': 'social-github',
|
||||
* 'data-namespace': 'header',
|
||||
* 'data-platform-title': 'Flow'
|
||||
* }
|
||||
*
|
||||
* Example - CTA Clicked:
|
||||
* {
|
||||
* 'data-event': 'CTA Clicked',
|
||||
* 'data-cta': 'Get Started',
|
||||
* 'data-channel': 'docs',
|
||||
* 'data-text': 'Get Started for Free'
|
||||
* }
|
||||
*
|
||||
* Note:
|
||||
* - data-event is required (becomes the event name)
|
||||
* - All other data-* attributes become event properties
|
||||
* - Follows IBM Segment Common Schema standards
|
||||
* - No onClick handler needed for tracking
|
||||
*/
|
||||
function initializeDataAttributeTracking() {
|
||||
// Only run on client side and prevent duplicate initialization
|
||||
if (!ExecutionEnvironment.canUseDOM || isDataAttributeTrackingInitialized) return;
|
||||
|
||||
const handleClick = (event) => {
|
||||
const target = event.target;
|
||||
const trackingElement = target.closest('[data-event]');
|
||||
|
||||
if (!trackingElement) return;
|
||||
|
||||
const eventName = trackingElement.dataset.event;
|
||||
if (!eventName) return;
|
||||
|
||||
// Extract all data-* attributes (except data-event itself)
|
||||
const properties = {};
|
||||
|
||||
Object.keys(trackingElement.dataset).forEach(key => {
|
||||
if (key !== 'event') {
|
||||
// Map to IBM Segment property names (preserve exact casing per schema)
|
||||
let propertyKey = key;
|
||||
|
||||
// Handle special IBM property mappings
|
||||
if (key === 'cta') propertyKey = 'CTA';
|
||||
else if (key === 'elementId') propertyKey = 'elementId';
|
||||
else if (key === 'topLevel') propertyKey = 'topLevel';
|
||||
else if (key === 'subLevel') propertyKey = 'subLevel';
|
||||
else if (key === 'platformTitle') propertyKey = 'platformTitle';
|
||||
|
||||
properties[propertyKey] = trackingElement.dataset[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Identify user before tracking
|
||||
identifyUser();
|
||||
|
||||
// Track the event with common properties
|
||||
trackEvent(eventName, properties);
|
||||
};
|
||||
|
||||
// Remove existing listener if it exists
|
||||
document.removeEventListener('click', handleClick);
|
||||
|
||||
// Add the new listener
|
||||
document.addEventListener('click', handleClick);
|
||||
|
||||
// Mark as initialized
|
||||
isDataAttributeTrackingInitialized = true;
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (ExecutionEnvironment.canUseDOM) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeDataAttributeTracking);
|
||||
} else {
|
||||
initializeDataAttributeTracking();
|
||||
}
|
||||
|
||||
// Re-initialize on route changes for SPA navigation
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(initializeDataAttributeTracking, 100);
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Custom Docusaurus plugin to inject Segment analytics
|
||||
function pluginSegment(context, options = {}) {
|
||||
const isProd = process.env.NODE_ENV === "production" || options.allowedInDev;
|
||||
const segmentPublicWriteKey = options.segmentPublicWriteKey;
|
||||
|
||||
if (!segmentPublicWriteKey) {
|
||||
console.warn('Segment plugin: No write key provided. Analytics will not be initialized.');
|
||||
return { name: 'docusaurus-plugin-segment' };
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'docusaurus-plugin-segment',
|
||||
|
||||
getClientModules() {
|
||||
return isProd ? [require.resolve('./analytics-page'), require.resolve('./data-attribute-tracking')] : [];
|
||||
},
|
||||
|
||||
injectHtmlTags() {
|
||||
if (!isProd) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
headTags: [
|
||||
// IBM Analytics and TrustArc Configuration
|
||||
{
|
||||
tagName: 'script',
|
||||
attributes: {},
|
||||
innerHTML: `
|
||||
window._ibmAnalytics = {
|
||||
"settings": {
|
||||
"name": "DataStax",
|
||||
"tealiumProfileName": "ibm-subsidiary",
|
||||
},
|
||||
"trustarc": {
|
||||
"privacyPolicyLink": "https://ibm.com/privacy"
|
||||
},
|
||||
"digitalData.page.services.google.enabled": true
|
||||
};
|
||||
window.digitalData = {
|
||||
"page": {
|
||||
"pageInfo": {
|
||||
"ibm": {
|
||||
"siteId": "IBM_" + _ibmAnalytics.settings.name,
|
||||
},
|
||||
segment: {
|
||||
enabled: true,
|
||||
env: 'prod',
|
||||
key: '${segmentPublicWriteKey}',
|
||||
coremetrics: false,
|
||||
carbonComponentEvents: false
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"primaryCategory": "PC230"
|
||||
}
|
||||
},
|
||||
"commonProperties": {
|
||||
"productTitle": "IBM Elite Support for Flow",
|
||||
"productCode": "5900BUB",
|
||||
"productCodeType": "WWPC",
|
||||
"UT30": "30AS5",
|
||||
"instanceId": "docs-site",
|
||||
"subscriptionId": "public-access",
|
||||
"productPlanName": "Public",
|
||||
"productPlanType": "freemium",
|
||||
"userId": "IBMid-ANONYMOUS"
|
||||
}
|
||||
};
|
||||
`,
|
||||
},
|
||||
// IBM Common Stats Script - handles Segment initialization
|
||||
{
|
||||
tagName: 'script',
|
||||
attributes: {
|
||||
src: '//1.www.s81c.com/common/stats/ibm-common.js',
|
||||
async: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = pluginSegment;
|
||||
Vendored
Vendored
-1
@@ -1 +0,0 @@
|
||||
docs.flow.org
|
||||
Reference in New Issue
Block a user