Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd95752a12
|
||
|
|
190a6f20ac
|
No files matched your search
@@ -0,0 +1,19 @@
|
||||
// ESLint flat config for the Forma workspaces (the shared Node profile runs `npm run lint --workspaces`,
|
||||
// each workspace linting its own src/ from here). typescript-eslint's recommended rules only: this repository
|
||||
// is not yet on the Kingsrook layout (K22-CODE-TS), so layout rules are deliberately not enabled here.
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/coverage/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
// An underscore prefix marks a parameter kept for the signature but not used.
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1215
-3
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"description": "Publish Markdown documentation to Confluence",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/publisher",
|
||||
"packages/cli"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
@@ -27,5 +28,11 @@
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^10.14.0",
|
||||
"typescript-eslint": "^8.72.1"
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ interface PlanOptions {
|
||||
}
|
||||
|
||||
export async function planCommand(options: PlanOptions): Promise<void> {
|
||||
const config = await loadConfig(options.config);
|
||||
await loadConfig(options.config);
|
||||
// TODO: Implement plan (dry-run)
|
||||
console.log('Planning with config:', options.config);
|
||||
process.exit(0);
|
||||
|
||||
@@ -12,7 +12,7 @@ interface PublishOptions {
|
||||
}
|
||||
|
||||
export async function publishCommand(options: PublishOptions): Promise<void> {
|
||||
const config = await loadConfig(options.config);
|
||||
await loadConfig(options.config);
|
||||
// TODO: Implement publish
|
||||
console.log('Publishing with config:', options.config);
|
||||
process.exit(0);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface RenderOptions {
|
||||
}
|
||||
|
||||
export async function renderCommand(options: RenderOptions): Promise<void> {
|
||||
const config = await loadConfig(options.config);
|
||||
await loadConfig(options.config);
|
||||
// TODO: Implement render
|
||||
console.log('Rendering with config:', options.config);
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'forma-config-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('parses a YAML config', async () => {
|
||||
const path = join(dir, '.confluence-publish.yml');
|
||||
await writeFile(path, 'version: 1\nconfluence:\n baseUrl: https://example.atlassian.net/wiki\n spaceKey: DOC\n');
|
||||
|
||||
const config = await loadConfig(path);
|
||||
|
||||
expect(config.version).toBe(1);
|
||||
expect(config.confluence.spaceKey).toBe('DOC');
|
||||
});
|
||||
|
||||
it('parses a JSON config when the file ends in .json', async () => {
|
||||
const path = join(dir, 'config.json');
|
||||
await writeFile(path, JSON.stringify({ version: 2, confluence: { baseUrl: 'https://x', spaceKey: 'K' } }));
|
||||
|
||||
const config = await loadConfig(path);
|
||||
|
||||
expect(config.version).toBe(2);
|
||||
expect(config.confluence.baseUrl).toBe('https://x');
|
||||
});
|
||||
|
||||
it('rejects when the file does not exist', async () => {
|
||||
await expect(loadConfig(join(dir, 'missing.yml'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed JSON instead of returning a partial config', async () => {
|
||||
const path = join(dir, 'broken.json');
|
||||
await writeFile(path, '{ "version": ');
|
||||
|
||||
await expect(loadConfig(path)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ export class ConfluenceClient {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? (contentType === 'application/json' ? JSON.stringify(body) : body as BodyInit) : undefined,
|
||||
body: body ? (contentType === 'application/json' ? JSON.stringify(body) : body as RequestInit['body']) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -402,7 +402,7 @@ export class ConfluenceClient {
|
||||
/**
|
||||
* Check if a specific macro/app is available
|
||||
*/
|
||||
async checkMacroAvailable(macroKey: string): Promise<boolean> {
|
||||
async checkMacroAvailable(_macroKey: string): Promise<boolean> {
|
||||
try {
|
||||
// Try to get app info - this varies by macro
|
||||
// For Mermaid, we check if the macro exists in content templates
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MarkdownConverter, type ConversionContext } from './converter.js';
|
||||
|
||||
const context: ConversionContext = {
|
||||
pageMap: new Map(),
|
||||
currentPageKey: 'docs/a.md',
|
||||
baseDir: 'docs',
|
||||
};
|
||||
|
||||
const convert = (markdown: string): string => new MarkdownConverter().convert(markdown, context);
|
||||
|
||||
describe('MarkdownConverter', () => {
|
||||
it('converts headings and escapes HTML in them', () => {
|
||||
expect(convert('## Fish & <Chips>')).toBe('<h2>Fish & <Chips></h2>');
|
||||
});
|
||||
|
||||
it('converts inline emphasis, code and external links in a paragraph', () => {
|
||||
const html = convert('Some **bold**, *italic*, `code` and [a link](https://example.com/x).');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).toContain('<em>italic</em>');
|
||||
expect(html).toContain('<code>code</code>');
|
||||
expect(html).toContain('<a href="https://example.com/x">a link</a>');
|
||||
});
|
||||
|
||||
it('renders a fenced code block as a Confluence code macro with the language', () => {
|
||||
const html = convert('```mermaid\ngraph TD; A-->B\n```');
|
||||
expect(html).toContain('<ac:structured-macro ac:name="code" ac:language="mermaid">');
|
||||
expect(html).toContain('<![CDATA[graph TD; A-->B]]>');
|
||||
});
|
||||
|
||||
it('renders bullet and numbered lists with the right tags', () => {
|
||||
expect(convert('- one\n- two')).toBe('<ul><li>one</li><li>two</li></ul>');
|
||||
expect(convert('1. one\n2. two')).toBe('<ol><li>one</li><li>two</li></ol>');
|
||||
});
|
||||
|
||||
it('references a local image as an attachment by file name', () => {
|
||||
expect(convert('')).toContain('<ri:attachment ri:filename="arch.png" />');
|
||||
});
|
||||
|
||||
it('drops front matter from the page body', () => {
|
||||
const html = convert('---\ntitle: Hidden\n---\n# Visible');
|
||||
expect(html).toBe('<h1>Visible</h1>');
|
||||
});
|
||||
});
|
||||
@@ -201,7 +201,7 @@ export class MarkdownConverter {
|
||||
}
|
||||
return this.renderCodeBlock(token.content!, token.lang);
|
||||
|
||||
case 'list':
|
||||
case 'list': {
|
||||
const listTag = token.ordered ? 'ol' : 'ul';
|
||||
const items = token.items!
|
||||
.map(itemTokens => {
|
||||
@@ -212,6 +212,7 @@ export class MarkdownConverter {
|
||||
})
|
||||
.join('');
|
||||
return `<${listTag}>${items}</${listTag}>`;
|
||||
}
|
||||
|
||||
case 'blockquote':
|
||||
return `<blockquote><p>${this.convertInline(token.content!, context)}</p></blockquote>`;
|
||||
@@ -298,7 +299,7 @@ export class MarkdownConverter {
|
||||
return result;
|
||||
}
|
||||
|
||||
private convertImage(alt: string, url: string, context: ConversionContext): string {
|
||||
private convertImage(alt: string, url: string, _context: ConversionContext): string {
|
||||
// External URL
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return `<ac:image><ri:url ri:value="${this.escapeHtml(url)}" /></ac:image>`;
|
||||
@@ -309,7 +310,7 @@ export class MarkdownConverter {
|
||||
return `<ac:image><ri:attachment ri:filename="${this.escapeHtml(fileName)}" /></ac:image>`;
|
||||
}
|
||||
|
||||
private convertLink(text: string, url: string, context: ConversionContext): string {
|
||||
private convertLink(text: string, url: string, _context: ConversionContext): string {
|
||||
// External URL
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return `<a href="${this.escapeHtml(url)}">${text}</a>`;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TreeBuilder } from './builder.js';
|
||||
import type { Config } from '../types.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'forma-tree-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function configFor(localRoot: string): Config {
|
||||
return {
|
||||
version: 1,
|
||||
confluence: { baseUrl: 'https://example.atlassian.net/wiki', spaceKey: 'DOC' },
|
||||
mappings: [{ localRoot }],
|
||||
publish: { include: ['**/*.md'], exclude: ['**/drafts/**'], allowDelete: false, dryRun: true },
|
||||
mapping: {
|
||||
titleFrom: 'h1-or-filename',
|
||||
indexFiles: ['README.md', 'index.md'],
|
||||
folderIndexBehavior: 'index-as-folder-page',
|
||||
fileNameTitleCase: false,
|
||||
preserveNumericPrefixes: true,
|
||||
pageTitlePrefix: '',
|
||||
pageTitleSuffix: '',
|
||||
},
|
||||
markdown: {
|
||||
mermaidMacro: 'auto-detect',
|
||||
enablePlantUmlMacro: false,
|
||||
codeBlockTheme: 'default',
|
||||
preserveHtml: false,
|
||||
convertRelativeLinks: true,
|
||||
warnOnCrossMappingLinks: true,
|
||||
treatUnknownLinksAsText: true,
|
||||
},
|
||||
attachments: { upload: true, uploadStrategy: 'per-page', includeGlobs: ['**/*.png'], maxBytes: 1_000_000 },
|
||||
metadata: { addLabelsFromFrontmatter: true, addPublishMetadata: false },
|
||||
logging: { level: 'info', showDiffSummary: false },
|
||||
};
|
||||
}
|
||||
|
||||
describe('TreeBuilder', () => {
|
||||
it('builds one page per markdown file, titled from the first H1, and ignores other files', async () => {
|
||||
await writeFile(join(root, 'index.md'), '# Home\n\nWelcome.');
|
||||
await writeFile(join(root, 'guide.md'), '# The Guide\n\nText.');
|
||||
await writeFile(join(root, 'notes.txt'), 'not markdown');
|
||||
|
||||
const result = await new TreeBuilder(configFor(root)).build();
|
||||
|
||||
const titles = [...result.pages.values()].map(page => page.title).sort();
|
||||
expect(titles).toEqual(['Home', 'The Guide']);
|
||||
});
|
||||
|
||||
it('falls back to the file name when there is no H1', async () => {
|
||||
await writeFile(join(root, 'untitled.md'), 'Just text.');
|
||||
|
||||
const result = await new TreeBuilder(configFor(root)).build();
|
||||
|
||||
expect([...result.pages.values()].map(page => page.title)).toEqual(['untitled']);
|
||||
});
|
||||
|
||||
it('honours publish.exclude and records nested pages', async () => {
|
||||
await mkdir(join(root, 'drafts'));
|
||||
await mkdir(join(root, 'sub'));
|
||||
await writeFile(join(root, 'drafts', 'wip.md'), '# Work in progress');
|
||||
await writeFile(join(root, 'sub', 'page.md'), '# Nested');
|
||||
|
||||
const result = await new TreeBuilder(configFor(root)).build();
|
||||
|
||||
const titles = [...result.pages.values()].map(page => page.title);
|
||||
expect(titles).toContain('Nested');
|
||||
expect(titles).not.toContain('Work in progress');
|
||||
});
|
||||
|
||||
it('reports local image references as attachments owned by their page', async () => {
|
||||
await writeFile(join(root, 'img.md'), '# Pic\n\n');
|
||||
await writeFile(join(root, 'arch.png'), 'x');
|
||||
|
||||
const result = await new TreeBuilder(configFor(root)).build();
|
||||
|
||||
expect(result.attachments.map(a => a.fileName)).toContain('arch.png');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Builds page tree from local file system
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative, dirname, basename, extname } from 'node:path';
|
||||
import type { Config, PageNode, AttachmentRef, Mapping } from '../types.js';
|
||||
|
||||
@@ -127,7 +127,7 @@ export class TreeBuilder {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
console.warn(`Warning: Could not read directory ${dir}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user