diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a3e499..5b0e086 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## Unreleased
+
+- **New Diagram…** (Explorer folder menu, *File → New File…*, Command Palette): pick Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan, enter a name, and a small valid `.archify` (YAML) file is created from a template and opened as a diagram. Title and output file follow the name.
+
## 0.3.0 — 2026-09-30
- **`.archify` files are YAML.** They are parsed as YAML and handed to the renderer as JSON. Older JSON `.archify` files still open, since JSON is valid YAML.
diff --git a/README.md b/README.md
index 1b65cde..9eae705 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i
## Quick start
+The fastest start: right-click a folder in the Explorer and choose **New Archify Diagram…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
+
+To write one by hand:
+
1. Create a file ending in `.archify`, for example `hello.archify`:
```yaml
@@ -41,6 +45,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so
## Features
+- **New diagram from a template**: *Archify: New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
- **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
@@ -48,7 +53,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so
- **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files.
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
-- **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
+- **Commands**: *Archify: New Diagram…*, *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
## Source evidence
diff --git a/package.json b/package.json
index 0043169..6a316c9 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,12 @@
],
"contributes": {
"commands": [
+ {
+ "command": "archify.newDiagram",
+ "title": "New Diagram\u2026",
+ "category": "Archify",
+ "icon": "$(new-file)"
+ },
{
"command": "archify.showPreview",
"title": "Open Preview",
@@ -145,6 +151,11 @@
}
],
"explorer/context": [
+ {
+ "command": "archify.newDiagram",
+ "when": "explorerResourceIsFolder",
+ "group": "navigation@10"
+ },
{
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
@@ -167,6 +178,12 @@
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "1_open"
}
+ ],
+ "file/newFile": [
+ {
+ "command": "archify.newDiagram",
+ "group": "file"
+ }
]
},
"keybindings": [
diff --git a/src/extension.ts b/src/extension.ts
index 4346be3..2f55f14 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -7,6 +7,7 @@ import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
+import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
@@ -61,6 +62,26 @@ export function activate(context: vscode.ExtensionContext): void {
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
+ const exists = async (uri: vscode.Uri) => {
+ try {
+ await vscode.workspace.fs.stat(uri);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ /** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
+ async function newDiagramFolder(arg?: unknown): Promise {
+ if (arg instanceof vscode.Uri) {
+ const stat = await vscode.workspace.fs.stat(arg);
+ return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
+ }
+ const folders = vscode.workspace.workspaceFolders ?? [];
+ if (folders.length <= 1) return folders[0]?.uri;
+ return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
+ }
+
context.subscriptions.push(
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
const uri = sourceUri(arg);
@@ -88,6 +109,44 @@ export function activate(context: vscode.ExtensionContext): void {
if (uri) return openHtmlViewer(uri);
}),
+ vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
+ const template = await vscode.window.showQuickPick(
+ TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
+ { title: 'New Archify Diagram', placeHolder: 'Kind of diagram' },
+ );
+ if (!template) return;
+ const { file, defaultName } = template.template;
+
+ const folder = await newDiagramFolder(arg);
+ let target: vscode.Uri | undefined;
+ if (folder) {
+ const name = await vscode.window.showInputBox({
+ title: 'New Archify Diagram',
+ prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
+ value: defaultName,
+ validateInput: async (value) =>
+ invalidNameReason(value) ??
+ ((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
+ });
+ if (name === undefined) return;
+ target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
+ } else {
+ // No workspace: let the user choose where to save.
+ target = await vscode.window.showSaveDialog({
+ title: 'New Archify Diagram',
+ filters: { 'Archify Diagram': ['archify'] },
+ saveLabel: 'Create',
+ });
+ if (!target) return;
+ if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
+ }
+
+ const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
+ const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
+ await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
+ await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
+ }),
+
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
vscode.commands.registerCommand('archify.showSource', async () => {
diff --git a/src/templates.ts b/src/templates.ts
new file mode 100644
index 0000000..79c2517
--- /dev/null
+++ b/src/templates.ts
@@ -0,0 +1,53 @@
+import { isMap, parseDocument } from 'yaml';
+import { DiagramType } from './detect';
+
+export interface DiagramTemplate {
+ /** File name under templates/. */
+ file: string;
+ type: DiagramType;
+ label: string;
+ description: string;
+ /** Suggested name for the new file, without .archify. */
+ defaultName: string;
+}
+
+export const TEMPLATES: readonly DiagramTemplate[] = [
+ { file: 'architecture.archify', type: 'architecture', label: 'Architecture', description: 'Parts of a system and how they connect', defaultName: 'architecture' },
+ { file: 'workflow.archify', type: 'workflow', label: 'Workflow', description: 'Steps of a process, in lanes', defaultName: 'workflow' },
+ { file: 'sequence.archify', type: 'sequence', label: 'Sequence', description: 'Who calls whom, in order', defaultName: 'sequence' },
+ { file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
+ { file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
+ { file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
+];
+
+/** Strips a typed .archify suffix and surrounding spaces. */
+export function diagramName(input: string): string {
+ return input.trim().replace(/\.archify$/i, '').trim();
+}
+
+/** Why a name cannot be used as a file name, or undefined when it can. */
+export function invalidNameReason(input: string): string | undefined {
+ const name = diagramName(input);
+ if (!name) return 'Enter a name.';
+ if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) return 'A name cannot contain \\ / : * ? " < > | or control characters.';
+ if (/^\.+$/.test(name)) return 'Enter a name.';
+ return undefined;
+}
+
+/** "sales-report_v2" → "Sales report v2". */
+export function titleFromName(name: string): string {
+ const words = name.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
+}
+
+/**
+ * Template YAML with meta.title and meta.output set for the new file.
+ * Works on the YAML document so the template's comments and layout stay.
+ */
+export function diagramFromTemplate(templateText: string, name: string): string {
+ const doc = parseDocument(templateText);
+ if (!isMap(doc.get('meta'))) doc.set('meta', doc.createNode({}));
+ doc.setIn(['meta', 'title'], titleFromName(name));
+ doc.setIn(['meta', 'output'], `${name}.html`);
+ return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
+}
diff --git a/templates/architecture.archify b/templates/architecture.archify
new file mode 100644
index 0000000..592a1c4
--- /dev/null
+++ b/templates/architecture.archify
@@ -0,0 +1,50 @@
+# Architecture: the parts of a system and how they connect.
+# Components need a unique id; connections link component ids.
+schema_version: 1
+diagram_type: architecture
+meta:
+ title: Web Application
+ output: architecture.html
+components:
+ - id: users
+ type: external
+ label: Users
+ sublabel: Browser
+ pos: [40, 160]
+ size: [130, 60]
+ - id: api
+ type: backend
+ label: API
+ sublabel: REST service
+ pos: [250, 160]
+ size: [130, 60]
+ - id: cache
+ type: database
+ label: Cache
+ sublabel: Redis
+ pos: [460, 60]
+ size: [130, 60]
+ - id: db
+ type: database
+ label: Database
+ sublabel: PostgreSQL
+ pos: [460, 260]
+ size: [130, 60]
+boundaries:
+ - kind: region
+ label: Production
+ wraps: [api, cache, db]
+connections:
+ - id: users-api
+ from: users
+ to: api
+ label: HTTPS
+ variant: emphasis
+ - id: api-cache
+ from: api
+ to: cache
+ label: read
+ - id: api-db
+ from: api
+ to: db
+ label: SQL
diff --git a/templates/dataflow.archify b/templates/dataflow.archify
new file mode 100644
index 0000000..d284df1
--- /dev/null
+++ b/templates/dataflow.archify
@@ -0,0 +1,55 @@
+# Data flow: where data comes from, how it is transformed, and where it ends up.
+# Nodes sit in stages (columns) and rows; flows link node ids.
+schema_version: 1
+diagram_type: dataflow
+meta:
+ title: Sales Reporting
+ output: dataflow.html
+ viewBox: [960, 420]
+stages:
+ - label: Sources
+ - label: Processing
+ - label: Storage
+ - label: Consumers
+nodes:
+ - id: shop
+ type: frontend
+ label: Web Shop
+ stage: 0
+ row: 0
+ - id: crm
+ type: external
+ label: CRM
+ stage: 0
+ row: 1
+ - id: etl
+ type: backend
+ label: Nightly ETL
+ stage: 1
+ row: 0
+ - id: warehouse
+ type: database
+ label: Warehouse
+ stage: 2
+ row: 0
+ - id: dashboard
+ type: frontend
+ label: Sales Dashboard
+ stage: 3
+ row: 0
+flows:
+ - from: shop
+ to: etl
+ label: orders
+ - from: crm
+ to: etl
+ label: customers
+ classification: PII
+ variant: security
+ - from: etl
+ to: warehouse
+ label: daily facts
+ variant: emphasis
+ - from: warehouse
+ to: dashboard
+ label: aggregates
diff --git a/templates/everyday-plan.archify b/templates/everyday-plan.archify
new file mode 100644
index 0000000..6e36a3f
--- /dev/null
+++ b/templates/everyday-plan.archify
@@ -0,0 +1,72 @@
+# Everyday plan: a workflow about daily life rather than software.
+# Node types set the colour; meta.legend renames them for the reader,
+# and icon picks the corner symbol (calendar, clock, person, briefcase, flag, moon).
+schema_version: 2
+diagram_type: workflow
+meta:
+ title: Summer Holiday Plan
+ output: everyday-plan.html
+ legend:
+ entries:
+ backend:
+ label: Time off
+ security:
+ label: Work
+ external:
+ label: Milestone
+lanes:
+ - id: plan
+ label: Plan
+ - id: work
+ label: Work
+mainPath: [today, book, holiday, back]
+nodes:
+ - id: today
+ lane: plan
+ col: 0
+ type: external
+ label: Today
+ icon: clock
+ - id: book
+ lane: plan
+ col: 1
+ type: external
+ label: Book flights
+ sublabel: by end of May
+ icon: flag
+ - id: holiday
+ lane: plan
+ col: 2
+ type: backend
+ label: Holiday
+ sublabel: 2 weeks
+ icon: calendar
+ - id: back
+ lane: plan
+ col: 3
+ type: external
+ label: Back home
+ icon: flag
+ - id: handover
+ lane: work
+ col: 1
+ type: security
+ label: Handover
+ icon: briefcase
+edges:
+ - id: plan-booking
+ from: today
+ to: book
+ - id: go
+ from: book
+ to: holiday
+ label: leave approved
+ - id: return
+ from: holiday
+ to: back
+ - id: prepare
+ from: today
+ to: handover
+ label: before leaving
+ role: branch
+ variant: security
diff --git a/templates/lifecycle.archify b/templates/lifecycle.archify
new file mode 100644
index 0000000..2c8125b
--- /dev/null
+++ b/templates/lifecycle.archify
@@ -0,0 +1,53 @@
+# Lifecycle: the states something moves through and what moves it.
+# States sit in lanes and columns; transitions link state ids.
+schema_version: 1
+diagram_type: lifecycle
+meta:
+ title: Order Status
+ output: lifecycle.html
+ viewBox: [880, 566]
+lanes:
+ - id: main
+ label: Normal path
+ - id: exits
+ label: Exits
+states:
+ - id: placed
+ type: start
+ label: Placed
+ lane: main
+ col: 0
+ - id: paid
+ type: active
+ label: Paid
+ lane: main
+ col: 1
+ - id: shipped
+ type: active
+ label: Shipped
+ lane: main
+ col: 2
+ - id: delivered
+ type: success
+ label: Delivered
+ lane: main
+ col: 3
+ - id: cancelled
+ type: failure
+ label: Cancelled
+ lane: exits
+ col: 0
+transitions:
+ - id: pay
+ from: placed
+ to: paid
+ - id: ship
+ from: paid
+ to: shipped
+ - id: deliver
+ from: shipped
+ to: delivered
+ - id: cancel
+ from: placed
+ to: cancelled
+ variant: security
diff --git a/templates/sequence.archify b/templates/sequence.archify
new file mode 100644
index 0000000..9b31010
--- /dev/null
+++ b/templates/sequence.archify
@@ -0,0 +1,50 @@
+# Sequence: who calls whom, in order. Participants are columns;
+# messages run top to bottom by y.
+schema_version: 1
+diagram_type: sequence
+meta:
+ title: Login Request
+ output: sequence.html
+ viewBox: [960, 520]
+participants:
+ - id: client
+ type: external
+ label: Client
+ - id: api
+ type: backend
+ label: API
+ - id: auth
+ type: security
+ label: Auth Service
+ - id: db
+ type: database
+ label: User Store
+messages:
+ - from: client
+ to: api
+ y: 160
+ label: POST /login
+ variant: emphasis
+ - from: api
+ to: auth
+ y: 210
+ label: verify credentials
+ - from: auth
+ to: db
+ y: 260
+ label: find user
+ - from: db
+ to: auth
+ y: 310
+ label: user record
+ variant: return
+ - from: auth
+ to: api
+ y: 360
+ label: token
+ variant: return
+ - from: api
+ to: client
+ y: 410
+ label: 200 OK + token
+ variant: return
diff --git a/templates/workflow.archify b/templates/workflow.archify
new file mode 100644
index 0000000..bd8b1a7
--- /dev/null
+++ b/templates/workflow.archify
@@ -0,0 +1,70 @@
+# Workflow: steps of a process, grouped in lanes, left to right by col.
+# mainPath lists the happy path; edges link node ids.
+schema_version: 2
+diagram_type: workflow
+meta:
+ title: Change Approval
+ output: workflow.html
+ # Rename the legend entries of the node types you use.
+ legend:
+ entries:
+ frontend:
+ label: Author step
+ security:
+ label: Review gate
+ backend:
+ label: Decision
+ external:
+ label: Outcome
+lanes:
+ - id: author
+ label: Author
+ - id: review
+ label: Reviewers
+mainPath: [draft, review, approved, released]
+nodes:
+ - id: draft
+ lane: author
+ col: 0
+ type: frontend
+ label: Draft change
+ - id: review
+ lane: review
+ col: 1
+ type: security
+ label: Review
+ sublabel: two approvals
+ - id: approved
+ lane: review
+ col: 2
+ type: backend
+ label: Approved
+ - id: released
+ lane: author
+ col: 3
+ type: external
+ label: Released
+ - id: rework
+ lane: author
+ col: 1
+ type: frontend
+ label: Rework
+edges:
+ - id: submit
+ from: draft
+ to: review
+ label: submit
+ - id: approve
+ from: review
+ to: approved
+ label: approve
+ - id: release
+ from: approved
+ to: released
+ label: release
+ - id: request-changes
+ from: review
+ to: rework
+ label: changes requested
+ role: branch
+ variant: dashed
diff --git a/test/integration/suite.ts b/test/integration/suite.ts
index 1a10ea1..2210dd2 100644
--- a/test/integration/suite.ts
+++ b/test/integration/suite.ts
@@ -97,6 +97,35 @@ export async function run(): Promise {
assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`);
});
+ await check('New Diagram creates a .archify file from a template and opens the diagram', async () => {
+ const folder = workspaceFile('.');
+ // Stub the template pick and the name prompt; check the name validation on the way.
+ const window = vscode.window as unknown as Record;
+ const originalPick = window.showQuickPick;
+ const originalInput = window.showInputBox;
+ let existingNameMessage: unknown;
+ window.showQuickPick = async (items: readonly { label: string }[]) => (await items).find((item) => item.label === 'Everyday Plan');
+ window.showInputBox = async (options: vscode.InputBoxOptions) => {
+ existingNameMessage = await options.validateInput?.('web-platform');
+ return 'summer-plan';
+ };
+ try {
+ await vscode.commands.executeCommand('archify.newDiagram', folder);
+ } finally {
+ window.showQuickPick = originalPick;
+ window.showInputBox = originalInput;
+ }
+ assert.match(String(existingNameMessage), /already exists/);
+ const uri = vscode.Uri.joinPath(folder, 'summer-plan.archify');
+ const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
+ assert.match(text, /^diagram_type: workflow$/m);
+ assert.match(text, /^ {2}title: Summer plan$/m);
+ assert.match(text, /^ {2}output: summer-plan\.html$/m);
+ await waitFor('diagram editor for the new file', () =>
+ webviewTabs('archify.diagram').some((tab) => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString()),
+ );
+ });
+
await check('opens a live preview beside the source', async () => {
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
const tabs = await waitFor('preview tab', () => {
diff --git a/test/templates.test.ts b/test/templates.test.ts
new file mode 100644
index 0000000..1139abf
--- /dev/null
+++ b/test/templates.test.ts
@@ -0,0 +1,64 @@
+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 { parse } from 'yaml';
+import { readDiagramSource } from '../src/detect';
+import { validate } from '../src/renderer';
+import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/templates';
+
+const root = path.resolve(__dirname, '..', '..');
+const templates = path.join(root, 'templates');
+const runtime = {
+ cliPath: path.join(root, 'vendor', 'archify', 'bin', 'archify.mjs'),
+ nodePath: process.execPath,
+ runAsNode: false,
+ workDir: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-test-')),
+};
+
+test('every template file is listed and every listed template exists', () => {
+ const files = fs.readdirSync(templates).filter((file) => file.endsWith('.archify')).sort();
+ assert.deepEqual(TEMPLATES.map((item) => item.file).sort(), files);
+});
+
+for (const item of TEMPLATES) {
+ test(`template ${item.file} is a valid ${item.type} diagram`, async () => {
+ const text = diagramFromTemplate(fs.readFileSync(path.join(templates, item.file), 'utf8'), item.defaultName);
+ const source = readDiagramSource(text, `${item.defaultName}.archify`);
+ assert.ok(source);
+ assert.equal(source.type, item.type);
+ assert.equal(source.format, 'yaml');
+ const result = await validate(runtime, source.type, source.json);
+ assert.deepEqual(
+ result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
+ [],
+ );
+ assert.ok(result.ok);
+ });
+}
+
+test('diagramFromTemplate sets title and output and keeps comments and flow lists', () => {
+ const text = diagramFromTemplate('# keep me\nschema_version: 1\nmeta:\n title: Old\n output: old.html\npos: [1, 2]\n', 'sales-report');
+ assert.match(text, /^# keep me/);
+ assert.match(text, /pos: \[1, 2\]/);
+ assert.deepEqual(parse(text).meta, { title: 'Sales report', output: 'sales-report.html' });
+});
+
+test('diagramFromTemplate adds meta when the template has none', () => {
+ assert.deepEqual(parse(diagramFromTemplate('diagram_type: workflow\n', 'plan')).meta, { title: 'Plan', output: 'plan.html' });
+});
+
+test('diagram names', () => {
+ assert.equal(diagramName(' web.archify '), 'web');
+ assert.equal(diagramName('web.ARCHIFY'), 'web');
+ assert.equal(invalidNameReason('web'), undefined);
+ assert.equal(invalidNameReason('my plan 2026'), undefined);
+ assert.ok(invalidNameReason(''));
+ assert.ok(invalidNameReason('.archify'));
+ assert.ok(invalidNameReason('..'));
+ assert.ok(invalidNameReason('a/b'));
+ assert.ok(invalidNameReason('a:b'));
+ assert.equal(titleFromName('sales-report_v2'), 'Sales report v2');
+ assert.equal(titleFromName('plan'), 'Plan');
+});