Skip to content
Merged
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
139 changes: 139 additions & 0 deletions packages/docs/src/cli/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,145 @@ describe("cloud cli", () => {
expect(docsJson.cloud.preview).toBeUndefined();
});

it("treats existing frameworkless docs.json as the cloud init source of truth", async () => {
mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
writeFileSync(path.join(tmpDir, "docs", "index.mdx"), "# Hello\n", "utf-8");
writeFileSync(
path.join(tmpDir, "docs.json"),
JSON.stringify(
{
version: 1,
docs: {
mode: "frameworkless",
runtime: "nextjs",
root: ".docs/site",
},
content: {
docsRoot: "docs",
apiReferenceRoot: "api-reference",
},
cloud: {
publish: { mode: "direct-commit", baseBranch: "stable" },
},
},
null,
2,
),
"utf-8",
);

const result = await initCloudConfig({
rootDir: tmpDir,
apiKeyEnv: "ACME_DOCS_CLOUD_KEY",
});
const docsJson = JSON.parse(readFileSync(path.join(tmpDir, "docs.json"), "utf-8"));

expect(result).toMatchObject({
configPath: path.join(tmpDir, "docs.json"),
docsJsonPath: path.join(tmpDir, "docs.json"),
apiKeyEnv: "ACME_DOCS_CLOUD_KEY",
configCreated: false,
configUpdated: false,
docsJsonCreated: false,
docsJsonUpdated: true,
});
expect(existsSync(path.join(tmpDir, "docs.config.ts"))).toBe(false);
expect(docsJson).toMatchObject({
docs: {
mode: "frameworkless",
runtime: "nextjs",
root: ".docs/site",
},
content: {
docsRoot: "docs",
apiReferenceRoot: "api-reference",
},
cloud: {
apiKey: { env: "ACME_DOCS_CLOUD_KEY" },
deploy: { enabled: true },
analytics: {
enabled: true,
console: false,
includeInputs: false,
},
publish: {
mode: "direct-commit",
baseBranch: "stable",
},
},
});

const secondResult = await initCloudConfig({
rootDir: tmpDir,
apiKeyEnv: "ACME_DOCS_CLOUD_KEY",
});

expect(secondResult).toMatchObject({
configCreated: false,
configUpdated: false,
docsJsonCreated: false,
docsJsonUpdated: false,
});
expect(existsSync(path.join(tmpDir, "docs.config.ts"))).toBe(false);
});

it("does not infer Fumadocs connect mode when docs.json is already the source", async () => {
writePackageJson({
next: "16.0.0",
"fumadocs-core": "16.7.16",
"fumadocs-ui": "16.7.16",
});
mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
mkdirSync(path.join(tmpDir, "content", "docs"), { recursive: true });
writeFileSync(path.join(tmpDir, "docs", "index.mdx"), "# Atomic docs.json\n", "utf-8");
writeFileSync(
path.join(tmpDir, "content", "docs", "index.mdx"),
"# Fumadocs signal\n",
"utf-8",
);
writeFileSync(path.join(tmpDir, "source.config.ts"), "export default {};\n", "utf-8");
writeFileSync(
path.join(tmpDir, "docs.json"),
JSON.stringify(
{
version: 1,
docs: {
mode: "frameworkless",
runtime: "nextjs",
root: ".docs/site",
},
content: {
docsRoot: "docs",
},
cloud: {
apiKey: { env: "EXISTING_DOCS_CLOUD_KEY" },
},
},
null,
2,
),
"utf-8",
);

const result = await initCloudConfig({ rootDir: tmpDir });
const materialized = await materializeCloudConfig({ rootDir: tmpDir });
const docsJson = JSON.parse(readFileSync(path.join(tmpDir, "docs.json"), "utf-8"));

expect(result.docsInfraProfile).toBeUndefined();
expect(result.configCreated).toBe(false);
expect(result.configPath).toBe(path.join(tmpDir, "docs.json"));
expect(materialized.updated).toBe(false);
expect(existsSync(path.join(tmpDir, "docs.config.ts"))).toBe(false);
expect(docsJson.content.docsRoot).toBe("docs");
expect(docsJson.extensions?.docsInfraProfile).toBeUndefined();
expect(docsJson.docs).toEqual({
mode: "frameworkless",
runtime: "nextjs",
root: ".docs/site",
});
expect(docsJson.cloud.apiKey.env).toBe("EXISTING_DOCS_CLOUD_KEY");
});

it("initializes Docs Cloud config, analytics, and docs.json", async () => {
writePackageJson();
mkdirSync(path.join(tmpDir, "app", "docs"), { recursive: true });
Expand Down
157 changes: 128 additions & 29 deletions packages/docs/src/cli/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1073,10 +1073,7 @@ function resolveConnectedDocsProfile(params: {
rootDir: string;
snapshot: DocsConfigSnapshot;
existing?: ManagedDocsJson;
explicit?: ConnectedDocsProfile;
}): ConnectedDocsProfile | undefined {
if (params.explicit) return params.explicit;

const shouldResolveConnectProfile =
params.snapshot.content?.includes(FUMADOCS_CONNECT_MARKER) || !params.snapshot.path;
if (!shouldResolveConnectProfile) return undefined;
Expand Down Expand Up @@ -1146,8 +1143,12 @@ function resolveDocsBlock(
existing?: ManagedDocsJson,
docsInfraProfile?: ConnectedDocsProfile,
): ManagedDocsJson["docs"] {
const detectedFramework = detectFramework(rootDir);
const existingDocs = existing?.docs;
if (!snapshot.path && existingDocs) {
return existingDocs;
}

const detectedFramework = detectFramework(rootDir);
const runtime =
detectedFramework ?? docsInfraProfile?.runtime ?? existingDocs?.runtime ?? "nextjs";
const hasFrameworkConfig = Boolean(snapshot.path || detectedFramework);
Expand All @@ -1166,10 +1167,13 @@ function resolveDocsBlock(
function resolveExtensions(
existing: ManagedDocsJson | undefined,
docsInfraProfile: ConnectedDocsProfile | undefined,
options: { dropStaleDocsInfraProfile?: boolean } = {},
): JsonRecord | undefined {
const existingExtensions = toJsonRecord(existing?.extensions);
if (!docsInfraProfile) {
if (!existingExtensions?.docsInfraProfile) return existingExtensions;
if (!existingExtensions?.docsInfraProfile || options.dropStaleDocsInfraProfile === false) {
return existingExtensions;
}

const { docsInfraProfile: _staleDocsInfraProfile, ...rest } = existingExtensions;
return Object.keys(rest).length > 0 ? rest : undefined;
Expand All @@ -1186,14 +1190,19 @@ function materializeDocsJsonObject(params: {
snapshot: DocsConfigSnapshot;
existing?: ManagedDocsJson;
docsInfraProfile?: ConnectedDocsProfile;
detectConnectedDocsProfile?: boolean;
dropStaleDocsInfraProfile?: boolean;
}): ManagedDocsJson {
const cloud = resolveCloudConfig(params.snapshot, params.existing);
const docsInfraProfile = resolveConnectedDocsProfile({
rootDir: params.rootDir,
snapshot: params.snapshot,
existing: params.existing,
explicit: params.docsInfraProfile,
});
const docsInfraProfile =
params.docsInfraProfile ??
(params.detectConnectedDocsProfile === false
? undefined
: resolveConnectedDocsProfile({
rootDir: params.rootDir,
snapshot: params.snapshot,
existing: params.existing,
}));
const docsRoot = resolveDocsRoot(
params.rootDir,
params.snapshot,
Expand All @@ -1203,7 +1212,9 @@ function materializeDocsJsonObject(params: {
const apiReferenceRoot = resolveApiReferenceRoot(params.snapshot);
const existingContent = toJsonRecord(params.existing?.content);
const site = resolveSiteConfig(params.rootDir, params.snapshot, params.existing);
const extensions = resolveExtensions(params.existing, docsInfraProfile);
const extensions = resolveExtensions(params.existing, docsInfraProfile, {
dropStaleDocsInfraProfile: params.dropStaleDocsInfraProfile,
});

const content: ManagedDocsJson["content"] = {
...existingContent,
Expand All @@ -1227,37 +1238,96 @@ export function serializeMaterializedDocsJson(config: ManagedDocsJson): string {
return `${JSON.stringify(config, null, 2)}\n`;
}

export async function materializeCloudConfig(
options: CloudCommandOptions = {},
): Promise<MaterializeCloudConfigResult> {
const rootDir = options.rootDir ?? process.cwd();
const docsJsonPath = path.join(rootDir, DOCS_JSON_FILE);
const existing = readExistingDocsJson(docsJsonPath);
const snapshot = await loadDocsConfigSnapshot(rootDir, options.configPath);
function withCloudInitDefaults(
cloud: DocsCloudConfig | undefined,
existingCloud: DocsCloudConfig | undefined,
apiKeyEnv: string,
): DocsCloudConfig {
const normalized = normalizeCloudConfig(cloud);

if (!existingCloud?.apiKey?.env) {
normalized.apiKey = { env: apiKeyEnv };
}

if (!normalized.deploy) {
normalized.deploy = { enabled: true };
}

if (typeof normalized.analytics === "undefined") {
normalized.analytics = {
enabled: true,
console: false,
includeInputs: false,
};
}

return normalizeCloudConfig(normalized);
}

function writeMaterializedCloudConfig(params: {
rootDir: string;
docsJsonPath: string;
existing?: ManagedDocsJson;
snapshot: DocsConfigSnapshot;
docsInfraProfile?: ConnectedDocsProfile;
cloudInitApiKeyEnv?: string;
detectConnectedDocsProfile?: boolean;
dropStaleDocsInfraProfile?: boolean;
}): MaterializeCloudConfigResult {
const config = materializeDocsJsonObject({
rootDir,
snapshot,
existing,
docsInfraProfile: options.docsInfraProfile,
rootDir: params.rootDir,
snapshot: params.snapshot,
existing: params.existing,
docsInfraProfile: params.docsInfraProfile,
detectConnectedDocsProfile: params.detectConnectedDocsProfile,
dropStaleDocsInfraProfile: params.dropStaleDocsInfraProfile,
});

if (params.cloudInitApiKeyEnv) {
config.cloud = withCloudInitDefaults(
config.cloud,
params.existing?.cloud,
params.cloudInitApiKeyEnv,
);
}

const serialized = serializeMaterializedDocsJson(config);
const previous = existing ? fs.readFileSync(docsJsonPath, "utf-8") : undefined;
const previous = params.existing ? fs.readFileSync(params.docsJsonPath, "utf-8") : undefined;
const updated = previous !== serialized;

if (updated) {
fs.writeFileSync(docsJsonPath, serialized, "utf-8");
fs.writeFileSync(params.docsJsonPath, serialized, "utf-8");
}

return {
configPath: snapshot.path ?? docsJsonPath,
docsJsonPath,
configPath: params.snapshot.path ?? params.docsJsonPath,
docsJsonPath: params.docsJsonPath,
config,
apiKeyEnv: config.cloud?.apiKey?.env ?? DOCS_CLOUD_DEFAULT_API_KEY_ENV,
created: !existing,
created: !params.existing,
updated,
};
}

export async function materializeCloudConfig(
options: CloudCommandOptions = {},
): Promise<MaterializeCloudConfigResult> {
const rootDir = options.rootDir ?? process.cwd();
const docsJsonPath = path.join(rootDir, DOCS_JSON_FILE);
const existing = readExistingDocsJson(docsJsonPath);
const snapshot = await loadDocsConfigSnapshot(rootDir, options.configPath);
const useDocsJsonAsSource = Boolean(existing && !snapshot.path);
return writeMaterializedCloudConfig({
rootDir,
docsJsonPath,
existing,
snapshot,
docsInfraProfile: options.docsInfraProfile,
detectConnectedDocsProfile: !useDocsJsonAsSource,
dropStaleDocsInfraProfile: !useDocsJsonAsSource,
});
}

function readCombinedEnv(rootDir: string): Record<string, string> {
const env: Record<string, string> = {
...loadProjectEnv(rootDir),
Expand Down Expand Up @@ -2380,11 +2450,40 @@ export async function syncCloudConfig(options: CloudCommandOptions = {}) {

export async function initCloudConfig(options: CloudCommandOptions = {}): Promise<CloudInitResult> {
const rootDir = options.rootDir ?? process.cwd();
const docsJsonPath = path.join(rootDir, DOCS_JSON_FILE);
const existingDocsJson = readExistingDocsJson(docsJsonPath);
const apiKeyEnv = normalizeEnvName(options.apiKeyEnv, DOCS_CLOUD_DEFAULT_API_KEY_ENV);
const existingConfigPath = tryResolveDocsConfigPath(rootDir, options.configPath);
const useDocsJsonAsSource = Boolean(existingDocsJson && !existingConfigPath);
const docsInfraProfile =
options.docsInfraProfile ??
(existingConfigPath ? undefined : detectConnectedFumadocsProfile(rootDir));
(existingConfigPath || existingDocsJson ? undefined : detectConnectedFumadocsProfile(rootDir));

if (useDocsJsonAsSource) {
const materialized = writeMaterializedCloudConfig({
rootDir,
docsJsonPath,
existing: existingDocsJson,
snapshot: {},
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
docsInfraProfile,
cloudInitApiKeyEnv: apiKeyEnv,
detectConnectedDocsProfile: false,
dropStaleDocsInfraProfile: false,
});

return {
configPath: materialized.configPath,
docsJsonPath: materialized.docsJsonPath,
apiKeyEnv: materialized.apiKeyEnv,
analyticsProjectIdEnv: DOCS_CLOUD_DEFAULT_ANALYTICS_PROJECT_ID_ENV,
configCreated: false,
configUpdated: false,
docsJsonCreated: false,
docsJsonUpdated: materialized.updated,
...(docsInfraProfile ? { docsInfraProfile } : {}),
};
}

const configUpdate = ensureDocsConfigCloudInit({
rootDir,
configPath: options.configPath,
Expand Down
Loading