Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/previews/sdk-versioning/v*/@reflag/** linguist-generated=true
/previews/sdk-versioning/v*/documents/** linguist-generated=true
/previews/sdk-versioning/v*/generated-manifest.json linguist-generated=true
6 changes: 6 additions & 0 deletions .gitbook.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Single-space Git Sync fallback for this SDK preview branch.
# Site-wide Git Sync uses gitbook-docs.yaml and each mapped space's config.
root: ./previews/sdk-versioning/v2/
structure:
readme: README.md
summary: SUMMARY.md
147 changes: 147 additions & 0 deletions .github/scripts/import-sdk-docs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";

export const packages = ["node-sdk", "browser-sdk", "react-sdk", "vue-sdk"];
const hash = (content) => createHash("sha256").update(content).digest("hex");
const read = (root, file) => fs.readFileSync(path.join(root, file), "utf8");

function banner(content, message) {
const note = `{% hint style="info" %}\n${message}\n{% endhint %}\n\n`;
if (content.startsWith("---\n")) {
const end = content.indexOf("\n---\n", 4);
assert(end >= 0, "Unclosed generated frontmatter");
return content.slice(0, end + 5) + "\n" + note + content.slice(end + 5).trimStart();
}
return note + content;
}

export function planImport({ source, major, commit }) {
assert(["1", "2"].includes(String(major)), "Select SDK major 1 or 2 explicitly");
assert(/^[a-f\d]{40}$/.test(commit), "A full source commit is required");
const files = new Map();
const versions = {};
for (const name of packages) {
const metadata = JSON.parse(read(source, `packages/${name}/package.json`));
assert.equal(metadata.name, `@reflag/${name}`);
assert.equal(metadata.version.split(".")[0], String(major), `Source major mismatch: ${name}`);
versions[metadata.name] = metadata.version;
const status =
String(major) === "1"
? "Legacy SDK v1."
: "SDK v2 candidate documentation, not a published release. Installation commands target the planned major; matching SDK/CLI releases are still required.";
const message = `${status} Generated from \`${metadata.name}@${metadata.version}\` in [the JavaScript repository](https://github.com/reflagcom/javascript/tree/${commit}/packages/${name}). SDK READMEs and code comments there remain authoritative.`;
for (const filename of ["README.md", "globals.md"]) {
const relative = `@reflag/${name}/${filename}`;
let content = read(source, `dist/docs/${relative}`);
if (filename === "README.md") {
// TypeDoc copies linked READMEs to a shared _media/README.md, which can
// collide with Native's linked README. Route the original source link.
const original = read(source, `packages/${name}/README.md`);
if (/\]\(\.\.\/browser-sdk\/README\.md\)/.test(original)) {
content = content.replaceAll("../../_media/README.md", "../browser-sdk/README.md");
}
content = content
.split("\n")
.map((line) => {
if (!/^\s*(?:npm (?:i|install)|yarn add|pnpm add)\b/.test(line)) return line;
return line.replace(
/@reflag\/(?:node-sdk|browser-sdk|react-sdk|vue-sdk)(?![\w/@-])/g,
`$&@${major}`,
);
})
.join("\n");
}
if (filename === "globals.md") {
// The canonical docs.sh strips synthetic property-anchor suffixes;
// normalize every link, including several links on the same table row.
content = content.replace(/(globals\.md#[\w-]+?)-\d+(?=[)"\s])/g, "$1");
content = content.replace(/```[^\n]*\n[\s\S]*?```/g, (block) =>
block.replace(/[ \t]+$/gm, ""),
);
}
assert(!content.includes("_media/README.md"), `Unmapped README attachment: ${relative}`);
files.set(relative, Buffer.from(banner(content, message)));
}
}
files.set(
"documents/browser-sdk/FEEDBACK.md",
Buffer.from(read(source, "dist/docs/documents/browser-sdk/FEEDBACK.md")),
);
// Only copy media actually referenced by these generated pages, never the
// mixed all-package index or independent Native/Management/CLI documentation.
// Snapshot Markdown pages before appending binary media to the output map.
const pages = [...files.values()];
for (const content of pages) {
for (const match of content.toString().matchAll(/(?:\.\.\/)+_media\/([^\s)"#]+)(?:[)#"\s])/g)) {
const name = decodeURIComponent(match[1]);
assert(!name.includes("..") && !path.isAbsolute(name), "Unsafe media path");
const relative = `_media/${name}`;
files.set(relative, fs.readFileSync(path.join(source, "dist/docs", relative)));
}
}
const manifest = {
status: String(major) === "1" ? "published-v1-sources" : "candidate-source-build-not-a-release",
sourceRepository: "https://github.com/reflagcom/javascript",
sourceCommit: commit,
sdkMajor: Number(major),
packages: versions,
transforms: [
"GitBook version/source banner",
"major-pinned install commands",
"source-linked README routing",
"TypeDoc property-anchor normalization",
"TypeDoc code-block trailing whitespace",
],
files: Object.fromEntries([...files].map(([name, content]) => [name, hash(content)])),
};
return { files, manifest };
}

export function applyImport(plan, destination, { check = false } = {}) {
const files = new Map(plan.files);
files.set("generated-manifest.json", Buffer.from(`${JSON.stringify(plan.manifest, null, 2)}\n`));
for (const [relative, content] of files) {
const target = path.join(destination, relative);
if (check)
assert(fs.readFileSync(target).equals(content), `Generated SDK docs differ: ${relative}`);
else {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}
}
// Landing, navigation and .gitbook.yaml are separately owned; never delete
// directories or touch the original sdk/ tree or other versions.
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const { values } = parseArgs({
options: {
source: { type: "string" },
destination: { type: "string" },
major: { type: "string" },
check: { type: "boolean", default: false },
},
});
assert(
values.source && values.destination && values.major,
"Use --source <clean-built-SDK-checkout> --destination <variant-directory> --major 1|2 [--check]",
);
const source = path.resolve(values.source);
const git = (...args) =>
execFileSync("git", ["-C", source, ...args], { encoding: "utf8" }).trim();
assert.equal(
git("status", "--porcelain", "--untracked-files=no"),
"",
"Use a clean SDK source checkout",
);
const plan = planImport({ source, major: values.major, commit: git("rev-parse", "HEAD") });
applyImport(plan, path.resolve(values.destination), { check: values.check });
console.log(
`${values.check ? "Verified" : "Imported"} ${plan.files.size} generated files for SDK v${values.major} from ${plan.manifest.sourceCommit}.`,
);
}
77 changes: 77 additions & 0 deletions .github/scripts/import-sdk-docs.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { applyImport, packages, planImport } from "./import-sdk-docs.mjs";

function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "reflag-doc-import-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, "source"),
destination = path.join(root, "v2");
const put = (base, name, content) => {
const file = path.join(base, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
};
for (const name of packages) {
put(
source,
`packages/${name}/package.json`,
JSON.stringify({ name: `@reflag/${name}`, version: "2.0.0-next.0" }),
);
put(source, `packages/${name}/README.md`, "# SDK\n[Browser](../browser-sdk/README.md)\n");
put(
source,
`dist/docs/@reflag/${name}/README.md`,
"# SDK\n[Browser](../../_media/README.md)\nnpm install @reflag/react-sdk\n",
);
put(source, `dist/docs/@reflag/${name}/globals.md`, "# Reference\n");
}
put(source, "dist/docs/documents/browser-sdk/FEEDBACK.md", "# Feedback\n");
put(source, "dist/docs/README.md", "Mixed unpublished/independent package index");
return { source, destination, put, options: { source, major: "2", commit: "a".repeat(40) } };
}

test("imports nine generated pages without overwriting landing/navigation/other versions", (t) => {
const { destination, put, options } = fixture(t);
put(destination, "README.md", "handwritten landing");
put(destination, "SUMMARY.md", "navigation");
const plan = planImport(options);
applyImport(plan, destination);
applyImport(plan, destination, { check: true });
assert.equal(plan.files.size, 9);
assert.equal(fs.readFileSync(path.join(destination, "README.md"), "utf8"), "handwritten landing");
assert.equal(fs.readFileSync(path.join(destination, "SUMMARY.md"), "utf8"), "navigation");
assert(!fs.existsSync(path.join(destination, "@reflag/management-sdk")));
});
test("generated README links point at canonical SDK pages instead of shared attachments", (t) => {
const { options } = fixture(t);
const plan = planImport(options);
assert.match(
plan.files.get("@reflag/react-sdk/README.md").toString(),
/\(\.\.\/browser-sdk\/README.md\)/,
);
assert.match(
plan.files.get("@reflag/react-sdk/README.md").toString(),
/npm install @reflag\/react-sdk@2/,
);
});
test("wrong source majors fail before writes", (t) => {
const { destination, options } = fixture(t);
assert.throws(() => planImport({ ...options, major: "1" }), /Source major mismatch/);
assert(!fs.existsSync(destination));
});
test("missing feedback fails the complete plan", (t) => {
const { source, options } = fixture(t);
fs.unlinkSync(path.join(source, "dist/docs/documents/browser-sdk/FEEDBACK.md"));
assert.throws(() => planImport(options), /ENOENT/);
});
test("changed generated output fails reproducibility checking", (t) => {
const { destination, put, options } = fixture(t);
const plan = planImport(options);
applyImport(plan, destination);
put(destination, "@reflag/node-sdk/globals.md", "manual edit");
assert.throws(() => applyImport(plan, destination, { check: true }), /Generated SDK docs differ/);
});
74 changes: 74 additions & 0 deletions .github/workflows/sdk-versioning-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: SDK versioning preview

on:
pull_request:
paths:
- previews/sdk-versioning/**
- gitbook-docs.yaml
- .gitbook.yaml
- .gitattributes
- .github/scripts/**
- .github/workflows/sdk-versioning-preview.yml

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 24
- run: corepack enable
- name: Install preview checker
working-directory: previews/sdk-versioning
run: yarn install --immutable
- name: Check GitBook mappings, generated provenance, navigation and negative controls
working-directory: previews/sdk-versioning
run: yarn test
- name: Check out published v1 source
uses: actions/checkout@v4
with:
repository: reflagcom/javascript
ref: b4fe5b3d9386b87c6546175c323862d9878e1430
path: .sdk-v1
persist-credentials: false
- name: Rebuild published v1 declarations and docs
working-directory: .sdk-v1
run: |
yarn install --immutable
yarn workspace @reflag/browser-sdk build
yarn workspace @reflag/react-sdk build
yarn workspace @reflag/node-sdk build
yarn workspace @reflag/vue-sdk build
# This published source's script declares /bin/sh but contains [[.
yarn exec bash ./docs.sh
- name: Check out the exact v2 candidate source
uses: actions/checkout@v4
with:
repository: reflagcom/javascript
ref: 9f3a0ae027e386747a2d4cf7683a68dcab70b355
path: .sdk-candidate
persist-credentials: false
- name: Rebuild candidate declarations and docs
working-directory: .sdk-candidate
run: |
yarn install --immutable
yarn workspace @reflag/flag-evaluation build
yarn workspace @reflag/node-sdk build
yarn workspace @reflag/browser-sdk build
yarn workspace @reflag/react-sdk build
yarn workspace @reflag/vue-sdk build
yarn exec bash ./docs.sh
- name: Verify generated pages against both source checkouts
run: |
node .github/scripts/import-sdk-docs.mjs --source .sdk-v1 --destination previews/sdk-versioning/v1 --major 1 --check
node .github/scripts/import-sdk-docs.mjs --source .sdk-candidate --destination previews/sdk-versioning/v2 --major 2 --check
- name: Compile six smoke examples against matching v1 and v2 declarations
working-directory: previews/sdk-versioning
run: yarn check:examples "$GITHUB_WORKSPACE/.sdk-candidate" "$GITHUB_WORKSPACE/.sdk-v1"
38 changes: 38 additions & 0 deletions gitbook-docs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SDK preview branch mapping, not a production-site migration.
# These are the SAME identity keys as the existing nested preview project.
# Do not merge/apply to the live site without preserving its production keys.
$schema: https://api.gitbook.com/gitbook-docs.yaml
site:
title: Reflag docs — SDK versioning preview
structure:
- type: section
key: sdk-preview-javascript
title: JavaScript SDKs
path: sdk
default: true
children:
- type: space
key: sdk-preview-v2
title: v2 (candidate)
path: v2
default: true
content:
directory: ./previews/sdk-versioning/v2
- type: space
key: sdk-preview-v1
title: v1 (legacy)
path: v1
content:
directory: ./previews/sdk-versioning/v1
- type: section
key: sdk-preview-guides
title: Shared docs
path: guides
children:
- type: space
key: sdk-preview-shared
title: Shared docs
path: current
default: true
content:
directory: ./previews/sdk-versioning/shared
5 changes: 5 additions & 0 deletions previews/sdk-versioning/.gitbook.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Single-space preview fallback; native variants use the site mapping instead.
root: ./v2/
structure:
readme: README.md
summary: SUMMARY.md
3 changes: 3 additions & 0 deletions previews/sdk-versioning/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.yarn/
.pnp.*
1 change: 1 addition & 0 deletions previews/sdk-versioning/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
Loading
Loading