@@ -16,17 +16,201 @@ import { TempContext } from "../../../core/temp-types.ts";
1616import { downloadWithProgress } from "../../../core/download.ts" ;
1717import { withSpinner } from "../../../core/console.ts" ;
1818import { unzip } from "../../../core/zip.ts" ;
19- import { templateFiles } from "../../../extension/template.ts" ;
2019import { Command } from "cliffy/command/mod.ts" ;
2120import { initYamlIntelligenceResourcesFromFilesystem } from "../../../core/schema/utils.ts" ;
2221import { createTempContext } from "../../../core/temp.ts" ;
2322import { InternalError } from "../../../core/lib/error.ts" ;
2423import { notebookContext } from "../../../render/notebook/notebook-context.ts" ;
2524import { projectContext } from "../../../project/project-context.ts" ;
2625import { afterConfirm } from "../../../tools/tools-console.ts" ;
26+ import { readYaml } from "../../../core/yaml.ts" ;
27+ import { Metadata } from "../../../config/types.ts" ;
2728
2829const kRootTemplateName = "template.qmd" ;
2930
31+ // Brand extension detection result
32+ interface BrandExtensionInfo {
33+ isBrandExtension : boolean ;
34+ extensionDir ?: string ; // Directory containing the brand extension
35+ brandFileName ?: string ; // The original brand file name (e.g., "brand.yml")
36+ }
37+
38+ // Check if a directory contains a brand extension
39+ function checkForBrandExtension ( dir : string ) : BrandExtensionInfo {
40+ const extensionFiles = [ "_extension.yml" , "_extension.yaml" ] ;
41+
42+ for ( const file of extensionFiles ) {
43+ const path = join ( dir , file ) ;
44+ if ( existsSync ( path ) ) {
45+ try {
46+ const yaml = readYaml ( path ) as Metadata ;
47+ // Check for contributes.metadata.project.brand
48+ const contributes = yaml ?. contributes as Metadata | undefined ;
49+ const metadata = contributes ?. metadata as Metadata | undefined ;
50+ const project = metadata ?. project as Metadata | undefined ;
51+ const brandFile = project ?. brand as string | undefined ;
52+
53+ if ( brandFile && typeof brandFile === "string" ) {
54+ return {
55+ isBrandExtension : true ,
56+ extensionDir : dir ,
57+ brandFileName : brandFile ,
58+ } ;
59+ }
60+ } catch {
61+ // If we can't read/parse the extension file, continue searching
62+ }
63+ }
64+ }
65+
66+ return { isBrandExtension : false } ;
67+ }
68+
69+ // Search for a brand extension in the staged directory
70+ // Searches: root, _extensions/*, _extensions/*/*
71+ function findBrandExtension ( stagedDir : string ) : BrandExtensionInfo {
72+ // First check the root directory
73+ const rootCheck = checkForBrandExtension ( stagedDir ) ;
74+ if ( rootCheck . isBrandExtension ) {
75+ return rootCheck ;
76+ }
77+
78+ // Check _extensions directory
79+ const extensionsDir = join ( stagedDir , "_extensions" ) ;
80+ if ( ! existsSync ( extensionsDir ) ) {
81+ return { isBrandExtension : false } ;
82+ }
83+
84+ try {
85+ // Check direct children: _extensions/extension-name/
86+ for ( const entry of Deno . readDirSync ( extensionsDir ) ) {
87+ if ( ! entry . isDirectory ) continue ;
88+
89+ const extPath = join ( extensionsDir , entry . name ) ;
90+ const check = checkForBrandExtension ( extPath ) ;
91+ if ( check . isBrandExtension ) {
92+ return check ;
93+ }
94+
95+ // Check nested: _extensions/org/extension-name/
96+ for ( const nested of Deno . readDirSync ( extPath ) ) {
97+ if ( ! nested . isDirectory ) continue ;
98+ const nestedPath = join ( extPath , nested . name ) ;
99+ const nestedCheck = checkForBrandExtension ( nestedPath ) ;
100+ if ( nestedCheck . isBrandExtension ) {
101+ return nestedCheck ;
102+ }
103+ }
104+ }
105+ } catch {
106+ // Directory read error, return not found
107+ }
108+
109+ return { isBrandExtension : false } ;
110+ }
111+
112+ // Extract a path string from various formats:
113+ // - string: "path/to/file"
114+ // - object with path: { path: "path/to/file", alt: "..." }
115+ function extractPath ( value : unknown ) : string | undefined {
116+ if ( typeof value === "string" ) {
117+ return value ;
118+ }
119+ if ( value && typeof value === "object" && "path" in value ) {
120+ const pathValue = ( value as Record < string , unknown > ) . path ;
121+ if ( typeof pathValue === "string" ) {
122+ return pathValue ;
123+ }
124+ }
125+ return undefined ;
126+ }
127+
128+ // Check if a path is a local file (not a URL)
129+ function isLocalPath ( path : string ) : boolean {
130+ return ! path . startsWith ( "http://" ) && ! path . startsWith ( "https://" ) ;
131+ }
132+
133+ // Extract all referenced file paths from a brand YAML file
134+ function extractBrandFilePaths ( brandYamlPath : string ) : string [ ] {
135+ const paths : string [ ] = [ ] ;
136+
137+ try {
138+ const yaml = readYaml ( brandYamlPath ) as Metadata ;
139+ if ( ! yaml ) return paths ;
140+
141+ // Extract logo paths
142+ const logo = yaml . logo as Metadata | undefined ;
143+ if ( logo ) {
144+ // Handle logo.images (named resources)
145+ // Format: logo.images.<name> can be string or { path, alt }
146+ const images = logo . images as Metadata | undefined ;
147+ if ( images && typeof images === "object" ) {
148+ for ( const value of Object . values ( images ) ) {
149+ const path = extractPath ( value ) ;
150+ if ( path && isLocalPath ( path ) ) {
151+ paths . push ( path ) ;
152+ }
153+ }
154+ }
155+
156+ // Handle logo.small, logo.medium, logo.large
157+ // Format: string or { light: string, dark: string }
158+ for ( const size of [ "small" , "medium" , "large" ] ) {
159+ const sizeValue = logo [ size ] ;
160+ if ( ! sizeValue ) continue ;
161+
162+ if ( typeof sizeValue === "string" ) {
163+ if ( isLocalPath ( sizeValue ) ) {
164+ paths . push ( sizeValue ) ;
165+ }
166+ } else if ( typeof sizeValue === "object" ) {
167+ // Handle { light: "...", dark: "..." }
168+ const lightDark = sizeValue as Record < string , unknown > ;
169+ if (
170+ typeof lightDark . light === "string" && isLocalPath ( lightDark . light )
171+ ) {
172+ paths . push ( lightDark . light ) ;
173+ }
174+ if (
175+ typeof lightDark . dark === "string" && isLocalPath ( lightDark . dark )
176+ ) {
177+ paths . push ( lightDark . dark ) ;
178+ }
179+ }
180+ }
181+ }
182+
183+ // Extract typography font file paths
184+ const typography = yaml . typography as Metadata | undefined ;
185+ if ( typography ) {
186+ const fonts = typography . fonts as unknown [ ] | undefined ;
187+ if ( Array . isArray ( fonts ) ) {
188+ for ( const font of fonts ) {
189+ if ( ! font || typeof font !== "object" ) continue ;
190+ const fontObj = font as Record < string , unknown > ;
191+
192+ // Only process fonts with source: "file"
193+ if ( fontObj . source !== "file" ) continue ;
194+
195+ const files = fontObj . files as unknown [ ] | undefined ;
196+ if ( Array . isArray ( files ) ) {
197+ for ( const file of files ) {
198+ const path = extractPath ( file ) ;
199+ if ( path && isLocalPath ( path ) ) {
200+ paths . push ( path ) ;
201+ }
202+ }
203+ }
204+ }
205+ }
206+ }
207+ } catch {
208+ // If we can't read/parse the brand file, return empty list
209+ }
210+
211+ return paths ;
212+ }
213+
30214export const useBrandCommand = new Command ( )
31215 . name ( "brand" )
32216 . arguments ( "<target:string>" )
@@ -100,8 +284,44 @@ async function useBrand(
100284 // Extract and move the template into place
101285 const stagedDir = await stageBrand ( source , tempContext ) ;
102286
103- // Filter the list to template files
104- const filesToCopy = templateFiles ( stagedDir ) ;
287+ // Check if this is a brand extension
288+ const brandExtInfo = findBrandExtension ( stagedDir ) ;
289+
290+ // Determine the actual source directory and file mapping
291+ const sourceDir = brandExtInfo . isBrandExtension
292+ ? brandExtInfo . extensionDir !
293+ : stagedDir ;
294+
295+ // Find the brand file
296+ const brandFileName = brandExtInfo . isBrandExtension
297+ ? brandExtInfo . brandFileName !
298+ : existsSync ( join ( sourceDir , "_brand.yml" ) )
299+ ? "_brand.yml"
300+ : existsSync ( join ( sourceDir , "_brand.yaml" ) )
301+ ? "_brand.yaml"
302+ : undefined ;
303+
304+ if ( ! brandFileName ) {
305+ info ( "No brand file (_brand.yml or _brand.yaml) found in source" ) ;
306+ return ;
307+ }
308+
309+ const brandFilePath = join ( sourceDir , brandFileName ) ;
310+ // Get the directory containing the brand file (for resolving relative paths)
311+ const brandFileDir = dirname ( brandFilePath ) ;
312+
313+ // Extract referenced file paths from the brand YAML
314+ const referencedPaths = extractBrandFilePaths ( brandFilePath ) ;
315+
316+ // Build list of files to copy: brand file + referenced files
317+ // Referenced paths are relative to the brand file's directory
318+ const filesToCopy : string [ ] = [ brandFilePath ] ;
319+ for ( const refPath of referencedPaths ) {
320+ const fullPath = join ( brandFileDir , refPath ) ;
321+ if ( existsSync ( fullPath ) ) {
322+ filesToCopy . push ( fullPath ) ;
323+ }
324+ }
105325
106326 // Confirm changes to brand directory (skip for dry-run or force)
107327 if ( ! options . dryRun && ! options . force ) {
@@ -125,10 +345,18 @@ async function useBrand(
125345 }
126346
127347 // Build set of source file paths for comparison
348+ // Paths are relative to the brand file's directory
349+ // For brand extensions, the brand file is renamed to _brand.yml
128350 const sourceFiles = new Set (
129351 filesToCopy
130352 . filter ( ( f ) => ! Deno . statSync ( f ) . isDirectory )
131- . map ( ( f ) => relative ( stagedDir , f ) ) ,
353+ . map ( ( f ) => {
354+ // If this is the brand file, it will become _brand.yml
355+ if ( f === brandFilePath ) {
356+ return "_brand.yml" ;
357+ }
358+ return relative ( brandFileDir , f ) ;
359+ } ) ,
132360 ) ;
133361
134362 // Find extra files in target that aren't in source
@@ -147,13 +375,22 @@ async function useBrand(
147375
148376 for ( const fileToCopy of filesToCopy ) {
149377 const isDir = Deno . statSync ( fileToCopy ) . isDirectory ;
150- const rel = relative ( stagedDir , fileToCopy ) ;
151378 if ( isDir ) {
152379 continue ;
153380 }
381+
382+ // Compute target path relative to brand file's directory
383+ // The brand file itself is renamed to _brand.yml
384+ let targetRel : string ;
385+ if ( fileToCopy === brandFilePath ) {
386+ targetRel = "_brand.yml" ;
387+ } else {
388+ targetRel = relative ( brandFileDir , fileToCopy ) ;
389+ }
390+
154391 // Compute the paths
155- const targetPath = join ( brandDir , rel ) ;
156- const displayName = rel ;
392+ const targetPath = join ( brandDir , targetRel ) ;
393+ const displayName = targetRel ;
157394 const targetDir = dirname ( targetPath ) ;
158395 const copyAction = {
159396 file : displayName ,
@@ -387,10 +624,10 @@ async function ensureBrandDirectory(force: boolean, dryRun: boolean) {
387624 const currentDir = Deno . cwd ( ) ;
388625 const nbContext = notebookContext ( ) ;
389626 const project = await projectContext ( currentDir , nbContext ) ;
390- if ( ! project ) {
391- throw new Error ( `Could not find project dir for ${ currentDir } ` ) ;
392- }
393- const brandDir = join ( project . dir , "_brand" ) ;
627+ // Use project directory if available, otherwise fall back to current directory
628+ // (single-file mode without _quarto.yml)
629+ const baseDir = project ?. dir ?? currentDir ;
630+ const brandDir = join ( baseDir , "_brand" ) ;
394631 if ( ! existsSync ( brandDir ) ) {
395632 if ( dryRun ) {
396633 info ( ` Would create directory: _brand/` ) ;
0 commit comments