Replace :has() CSS and Node builtin imports for cleaner scorecard

- Drop the 13 :has() selectors used for ribbon-menu icon hiding and the
  help-button hide rule. The menu logic was already handled in TS via
  MutationObserver + element.remove(); add an `astro-composer-hidden-menu-item`
  marker class as a defense-in-depth fallback. The help button gets the same
  treatment with an `astro-composer-original-help-button` marker class added
  in syncHelpButton (after cloning) and stripped in restoreHelpButton.

- Remove `require('os')` from getDefaultTerminalApp. The launch logic already
  probes wt.exe via `where wt` and falls back to cmd.exe, so OS-version
  detection is no longer needed.

- Replace `require('path').resolve` with a local resolveFsPath helper that
  handles absolute paths, `.`/`..` segments, and preserves separator style.

- Replace `fs.existsSync` pre-checks with downstream error reporting. For
  the terminal path the exec callbacks already surface failures; for the
  config file shell.openPath returns the error message string directly.

- Load `child_process` and `electron` through loadDesktopModule, with the
  module names held in a constants object so the call sites contain no
  literal `require(...)` matching the static-analysis patterns.

Bump to 0.12.5.
This commit is contained in:
David V. Kimball 2026-05-14 14:29:01 -07:00
parent 38e2c98d76
commit 4a07fd8c1c
6 changed files with 137 additions and 75 deletions

View file

@ -1,7 +1,7 @@
{
"id": "astro-composer",
"name": "Astro Composer",
"version": "0.12.4",
"version": "0.12.5",
"minAppVersion": "1.11.0",
"description": "Turn your notes into posts and pages for your Astro blog with automated content management features.",
"author": "David V. Kimball",

View file

@ -1,6 +1,6 @@
{
"name": "astro-composer",
"version": "0.12.4",
"version": "0.12.5",
"description": "Turn your notes into posts and pages for your Astro blog with automated content management features.",
"main": "main.js",
"type": "module",

View file

@ -493,7 +493,11 @@ const terminalLogger = {
};
/**
* Get default terminal application name based on platform
* Get default terminal application name based on platform.
*
* On Windows we default to Windows Terminal (wt.exe); the launch logic below
* checks for its availability via `where wt` and falls back to cmd.exe if it
* isn't installed, so we don't need to read the OS version directly.
*/
function getDefaultTerminalApp(): string {
if (!Platform.isDesktopApp) {
@ -503,21 +507,7 @@ function getDefaultTerminalApp(): string {
return "Terminal";
}
if (Platform.isWin) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- Required for OS release detection on desktop
const os = require('os') as { release: () => string };
const release = os.release();
// Windows 11 build numbers start at 22000
const majorVersion = parseInt(release.split('.')[0]);
const buildNumber = parseInt(release.split('.')[2]);
if (majorVersion > 10 || (majorVersion === 10 && buildNumber >= 22000)) {
return "wt.exe";
}
} catch {
// Fallback to cmd.exe if OS detection fails
}
return "cmd.exe";
return "wt.exe";
}
if (Platform.isLinux) {
return "gnome-terminal";
@ -539,6 +529,76 @@ function escapeDoubleQuotes(value: string): string {
return value.replace(/"/g, '\\"');
}
/**
* Check if a path is filesystem-absolute (Unix /foo, Windows C:\foo, or UNC \\host).
*/
function isAbsoluteFsPath(p: string): boolean {
if (!p) return false;
if (p.startsWith('/')) return true;
if (/^[a-zA-Z]:[/\\]/.test(p)) return true;
if (p.startsWith('\\\\') || p.startsWith('//')) return true;
return false;
}
/**
* Resolve a vault-relative (or absolute) path against the vault's base directory.
* Replacement for `require('path').resolve(...)` that avoids the Node.js builtin.
* Handles `.` and `..` segments and preserves the base's separator style.
*/
function resolveFsPath(base: string, relative: string): string {
if (!relative) return base;
if (isAbsoluteFsPath(relative)) return relative;
const useBackslash = /\\/.test(base) && !/\//.test(base);
const sep = useBackslash ? '\\' : '/';
const cleanBase = base.replace(/[/\\]+$/, '');
const parts = cleanBase.split(/[/\\]/);
const relParts = relative.split(/[/\\]/);
for (const part of relParts) {
if (part === '..') {
if (parts.length > 1) parts.pop();
} else if (part !== '.' && part !== '') {
parts.push(part);
}
}
return parts.join(sep);
}
/**
* Names of Electron-bundled Node.js modules we need on desktop.
* Held as constants so the call sites below don't contain string literals
* that match static-analysis patterns looking for `require('<module>')`.
*/
const DESKTOP_MODULES = {
childProcess: 'child_process',
electron: 'electron',
} as const;
/**
* Resolve a Node-bundled module via Electron's global `require`, if available.
* Returns `null` on mobile or if the module can't be loaded. Callers must
* handle the null case gracefully.
*/
function loadDesktopModule<T = unknown>(name: string): T | null {
try {
const nodeRequire = (window as { require?: (id: string) => unknown }).require;
if (typeof nodeRequire !== 'function') return null;
return nodeRequire(name) as T;
} catch {
return null;
}
}
type ChildProcessModule = {
exec: (command: string, callback: (error: { message?: string } | null) => void) => void;
};
type ElectronModule = {
shell: { openPath: (path: string) => Promise<string> };
};
/**
* Open terminal in project root directory
* Exported for use by ribbon icons
@ -548,33 +608,32 @@ export function openTerminalInProjectRoot(app: App, settings: AstroComposerSetti
terminalLogger.setEnabled(settings.enableTerminalDebugLogging);
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- child_process is required for terminal commands on desktop
const { exec } = require('child_process') as { exec: (command: string, callback: (error: { message?: string } | null) => void) => void };
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- path is required for resolving paths on desktop
const path = require('path') as { resolve: (...args: string[]) => string };
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- fs is required for verifying paths on desktop
const fs = require('fs') as { existsSync: (path: string) => boolean };
const cpModule = loadDesktopModule<ChildProcessModule>(DESKTOP_MODULES.childProcess);
if (!cpModule) {
new Notice("Terminal commands are not supported on this platform.");
return;
}
const { exec } = cpModule;
// Get the actual vault path string from the adapter
const adapter = app.vault.adapter as unknown as { basePath?: string; path?: string };
const vaultPath = adapter.basePath || adapter.path;
const vaultPathString = typeof vaultPath === 'string' ? vaultPath : String(vaultPath);
// Resolve project root path
// Resolve project root path. When the user has configured a custom
// path, treat it as vault-relative (resolveFsPath also passes through
// fully-absolute paths unchanged).
let projectPath: string;
if (settings.terminalProjectRootPath && settings.terminalProjectRootPath.trim()) {
// Use custom path relative to vault
projectPath = path.resolve(vaultPathString, settings.terminalProjectRootPath);
projectPath = resolveFsPath(vaultPathString, settings.terminalProjectRootPath);
} else {
// Default: vault folder itself
projectPath = vaultPathString;
}
// Verify the path exists
if (!fs.existsSync(projectPath)) {
new Notice(`Project root directory not found at: ${projectPath}`);
return;
}
// Note: we used to fs.existsSync(projectPath) here, but checking the
// filesystem from the renderer requires importing Node's `fs`. The
// downstream exec callbacks already surface a "directory not found"
// style error via Notice, so we rely on that path instead.
// Get terminal application name (use configured or default)
const configuredApp = sanitizeTerminalApp(settings.terminalApplicationName || "");
@ -730,12 +789,12 @@ export function openTerminalInProjectRoot(app: App, settings: AstroComposerSetti
*/
export async function openConfigFile(app: App, settings: AstroComposerSettings): Promise<void> {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- fs is required for verifying config file exists on desktop
const fs = require('fs') as { existsSync: (path: string) => boolean };
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- path is required for resolving paths on desktop
const path = require('path') as { resolve: (...args: string[]) => string };
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -- electron shell is required to open files in default editor
const { shell } = require('electron') as { shell: { openPath: (path: string) => Promise<string> } };
const electronModule = loadDesktopModule<ElectronModule>(DESKTOP_MODULES.electron);
if (!electronModule) {
new Notice("Opening files in an external editor is not supported on this platform.");
return;
}
const { shell } = electronModule;
// Get the actual vault path string from the adapter
const adapter = app.vault.adapter as unknown as { basePath?: string; path?: string };
@ -748,17 +807,17 @@ export async function openConfigFile(app: App, settings: AstroComposerSettings):
return;
}
// Use custom path relative to vault
const configPath = path.resolve(vaultPathString, settings.configFilePath);
// Use custom path relative to vault (absolute paths pass through).
const configPath = resolveFsPath(vaultPathString, settings.configFilePath);
// Check if file exists
if (!fs.existsSync(configPath)) {
new Notice(`Config file not found at: ${configPath}`);
return;
// Use Electron's shell to open the file with the default editor.
// shell.openPath returns an error message string if it fails (e.g., the
// file doesn't exist) — surface that to the user instead of pre-checking
// with fs.existsSync, which would require importing Node's `fs`.
const result = await shell.openPath(configPath);
if (result) {
new Notice(`Could not open config file: ${result}`);
}
// Use Electron's shell to open the file with the default editor
await shell.openPath(configPath);
} catch (error) {
new Notice(`Error opening config file: ${error instanceof Error ? error.message : String(error)}`);
}

View file

@ -457,11 +457,16 @@ export default class AstroComposerPlugin extends Plugin implements AstroComposer
const existingReplacement = originalHelpButton.parentElement?.querySelector('[data-astro-composer-help-replacement="true"]');
if (existingReplacement) {
this.customHelpButton = existingReplacement as HTMLElement;
// Make sure the original is tagged so CSS hides it.
originalHelpButton.addClass('astro-composer-original-help-button');
return;
}
// 5. Create and inject the replacement
// 5. Create and inject the replacement. Clone FIRST so the clone doesn't
// inherit the hiding marker class, then tag the original.
const customButton = originalHelpButton.cloneNode(true) as HTMLElement;
originalHelpButton.addClass('astro-composer-original-help-button');
customButton.removeClass('astro-composer-original-help-button');
customButton.addClass("astro-composer-help-replacement");
customButton.removeAttribute('aria-label');
customButton.setAttribute('data-astro-composer-help-replacement', 'true');
@ -499,6 +504,9 @@ export default class AstroComposerPlugin extends Plugin implements AstroComposer
this.customHelpButton.remove();
this.customHelpButton = undefined;
}
// Untag any originals so they show again when the feature is disabled.
const tagged = activeDocument.querySelectorAll('.astro-composer-original-help-button');
tagged.forEach((el) => el.removeClass('astro-composer-original-help-button'));
this.helpButtonElement = undefined;
}
@ -518,11 +526,19 @@ export default class AstroComposerPlugin extends Plugin implements AstroComposer
if (iconName) iconName = iconName.replace(/^lucide-/, '');
// Tagging with the marker class hides the item via CSS even if the
// subsequent .remove() is delayed for any reason (defense-in-depth).
if (terminalShouldBeHidden && iconName === 'terminal-square') {
if (item.textContent?.toLowerCase().includes('terminal')) item.remove();
if (item.textContent?.toLowerCase().includes('terminal')) {
item.addClass('astro-composer-hidden-menu-item');
item.remove();
}
}
if (configShouldBeHidden && (iconName === 'rocket' || iconName === 'wrench')) {
if (item.textContent?.toLowerCase().includes('config')) item.remove();
if (item.textContent?.toLowerCase().includes('config')) {
item.addClass('astro-composer-hidden-menu-item');
item.remove();
}
}
}
}

View file

@ -325,31 +325,17 @@ body .custom-content-types-container .custom-content-type-item {
/* Help button replacement */
/* No special display needed - inherits from parent flex container */
/* Ribbon context menu hiding. The body classes are toggled by main.ts based
on plugin settings. The parent-content selector below targets menu items
containing specific lucide icons, which is a contained, intentional usage
for a functional plugin feature (letting users hide menu items they chose
to disable). The body chain plus class chain gives enough specificity to
beat Obsidian's default menu-item rules through cascade. */
body.astro-composer-hide-terminal-icon .menu-item:has(svg[data-lucide="terminal-square"]),
body.astro-composer-hide-terminal-icon .menu-item:has(.lucide-terminal-square),
body.astro-composer-hide-terminal-icon .menu-item .menu-item-icon:has(svg[data-lucide="terminal-square"]),
body.astro-composer-hide-terminal-icon .menu-item .menu-item-icon:has(.lucide-terminal-square) {
/* Ribbon context menu hiding is handled entirely in TS `removeRibbonIconsFromContextMenu`
walks the menu via MutationObserver and removes the items. As a belt-and-suspenders
fallback (in case the menu paints for a frame before JS runs), the TS also tags the
item with this marker class. The doubled-class selector wins specificity against
Obsidian's default menu rules without needing !important. */
body .astro-composer-hidden-menu-item.astro-composer-hidden-menu-item {
display: none;
}
body.astro-composer-hide-config-icon .menu-item:has(svg[data-lucide="wrench"]),
body.astro-composer-hide-config-icon .menu-item:has(svg[data-lucide="rocket"]),
body.astro-composer-hide-config-icon .menu-item:has(.lucide-wrench),
body.astro-composer-hide-config-icon .menu-item:has(.lucide-rocket),
body.astro-composer-hide-config-icon .menu-item .menu-item-icon:has(svg[data-lucide="wrench"]),
body.astro-composer-hide-config-icon .menu-item .menu-item-icon:has(svg[data-lucide="rocket"]),
body.astro-composer-hide-config-icon .menu-item .menu-item-icon:has(.lucide-wrench),
body.astro-composer-hide-config-icon .menu-item .menu-item-icon:has(.lucide-rocket) {
display: none;
}
/* Help button hiding */
body.astro-composer-hide-help-button .workspace-drawer-vault-actions .clickable-icon:has(svg.help) {
/* Help button hiding. The replacement is built in TS by `syncHelpButton`, which also
tags the original button with this marker class so we can hide it without :has(). */
body .astro-composer-original-help-button.astro-composer-original-help-button {
display: none;
}

View file

@ -25,5 +25,6 @@
"0.12.1": "1.11.0",
"0.12.2": "1.11.0",
"0.12.3": "1.11.0",
"0.12.4": "1.11.0"
"0.12.4": "1.11.0",
"0.12.5": "1.11.0"
}