#653 - Read Astro Content Collections

This commit is contained in:
Elio Struyf
2023-09-21 16:51:08 +02:00
parent 48ee263c27
commit 1128e230c6
31 changed files with 1025 additions and 126 deletions
+11
View File
@@ -23,6 +23,13 @@
"common.filter.value": "Filter by {0}",
"common.error.message": "Sorry, something went wrong.",
"common.openOnWebsite": "Open on website",
"common.settings": "Settings",
"settings.contentTypes": "Content types",
"settings.contentFolders": "Content folders",
"settings.diagnostic": "Diagnostic",
"settings.diagnostic.description": "You can run the diagnostics to check the whole Front Matter CMS configuration.",
"settings.diagnostic.link": "Run full diagnostics",
"developer.title": "Developer mode",
"developer.reload.title": "Reload the dashboard",
@@ -246,6 +253,7 @@
"dashboard.steps.stepsToGetStarted.showDashboard.description": "Once all actions are completed, the dashboard can be loaded.",
"dashboard.steps.stepsToGetStarted.template.name": "Use a configuration template",
"dashboard.steps.stepsToGetStarted.template.description": "Select a template to prefill the frontmatter.json file with the recommended settings.",
"dashboard.steps.stepsToGetStarted.astroContentTypes.name": "Create Content-Types for your Astro Content Collections",
"dashboard.taxonomyView.button.add.title": "Add {0} to taxonomy settings",
"dashboard.taxonomyView.button.edit.title": "Edit {0}",
@@ -286,6 +294,9 @@
"dashboard.media.detailsSlideOver.unmapped.description": "Do you want to remap the metadata of unmapped files?",
"dashboard.configuration.astro.astroContentTypes.empty": "No Astro Content Collections found.",
"dashboard.configuration.astro.astroContentTypes.description": "The following Astro Content Collections and can be used to generate a content-type.",
"panel.contentType.contentTypeValidator.title": "Content-type",
"panel.contentType.contentTypeValidator.hint": "We noticed field differences between the content-type and the front matter data. \n Would you like to create, update, or set the content-type for this content?",
"panel.contentType.contentTypeValidator.button.create": "Create content-type",
+1 -1
View File
@@ -30,7 +30,7 @@ const glob = require('glob');
for (const key of enKeys) {
// If the key does not exist in the file, add it
if (!content[key]) {
content[key] = `🚧: ${enContent[key]}`;
content[key] = `${enContent[key]}`;
}
}
+4 -2
View File
@@ -6,7 +6,7 @@ import {
SETTING_EXTENSIBILITY_SCRIPTS
} from '../constants';
import { join } from 'path';
import { commands, Uri, ViewColumn, Webview, WebviewPanel, window, workspace } from 'vscode';
import { commands, Uri, ViewColumn, Webview, WebviewPanel, window } from 'vscode';
import { Logger, Settings as SettingsHelper } from '../helpers';
import { DashboardCommand } from '../dashboardWebView/DashboardCommand';
import { Extension } from '../helpers/Extension';
@@ -23,7 +23,8 @@ import {
SnippetListener,
TaxonomyListener,
LogListener,
LocalizationListener
LocalizationListener,
SsgListener
} from '../listeners/dashboard';
import { MediaListener as PanelMediaListener } from '../listeners/panel';
import { GitListener, ModeListener } from '../listeners/general';
@@ -180,6 +181,7 @@ export class Dashboard {
GitListener.process(msg);
TaxonomyListener.process(msg);
LogListener.process(msg);
SsgListener.process(msg);
});
}
+12 -3
View File
@@ -109,9 +109,12 @@ export class Folders {
* Register the new folder path
* @param folderInfo
*/
public static async register(folderInfo: { title: string; path: Uri } | Uri) {
public static async register(
folderInfo: { title: string; path: Uri; contentType: string[] } | Uri
) {
let folderName = folderInfo instanceof Uri ? undefined : folderInfo.title;
const folder = folderInfo instanceof Uri ? folderInfo : folderInfo.path;
const contentType = folderInfo instanceof Uri ? undefined : folderInfo.contentType;
if (folder && folder.fsPath) {
const wslPath = folder.fsPath.replace(/\//g, '\\');
@@ -137,10 +140,16 @@ export class Folders {
});
}
folders.push({
const contentFolder = {
title: folderName,
path: folder.fsPath
} as ContentFolder);
} as ContentFolder;
if (contentType) {
contentFolder.contentTypes = typeof contentType === 'string' ? [contentType] : contentType;
}
folders.push(contentFolder);
folders = uniqBy(folders, (f) => f.path);
await Folders.update(folders);
+4
View File
@@ -0,0 +1,4 @@
export const SsgScripts = {
folder: '/ssg-scripts',
astroContentCollection: 'astro-collections.mjs'
};
+1
View File
@@ -14,6 +14,7 @@ export * from './NotificationType';
export * from './PreviewCommands';
export * from './SentryIgnore';
export * from './Snippet';
export * from './SsgScripts';
export * from './StaticFolderPlaceholder';
export * from './TelemetryEvent';
export * from './Templates';
+2
View File
@@ -14,6 +14,8 @@ export enum DashboardMessage {
addFolder = 'addFolder',
addAssetsFolder = 'addAssetsFolder',
triggerTemplate = 'triggerTemplate',
ssgGetAstroContentTypes = 'ssgGetAstroContentTypes',
ssgSetAstroContentTypes = 'ssgSetAstroContentTypes',
// Content dashboard
getData = 'getData',
+3
View File
@@ -21,6 +21,7 @@ import { ErrorView } from './ErrorView';
import { DashboardMessage } from '../DashboardMessage';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../localization';
import { SettingsView } from './SettingsView/SettingsView';
export interface IAppProps {
showWelcome: boolean;
@@ -133,6 +134,8 @@ Stack: ${componentStack}`
<Route path={routePaths.taxonomy} element={<TaxonomyView pages={pages} />} />
)}
<Route path={routePaths.settings} element={<SettingsView />} />
<Route path={`*`} element={<UnknownView />} />
</Routes>
</main>
@@ -0,0 +1,69 @@
import * as React from 'react';
import * as l10n from '@vscode/l10n';
import { messageHandler } from '@estruyf/vscode/dist/client';
import { DashboardMessage } from '../../../DashboardMessage';
import { AstroCollection } from '../../../../models';
import { Settings } from '../../../models';
import { SelectItem } from '../../Steps/SelectItem';
import { LocalizationKey } from '../../../../localization';
export interface IAstroContentTypesProps {
settings: Settings
triggerLoading: (isLoading: boolean) => void;
}
export const AstroContentTypes: React.FunctionComponent<IAstroContentTypesProps> = ({
settings,
triggerLoading
}: React.PropsWithChildren<IAstroContentTypesProps>) => {
const [collections, setCollections] = React.useState<AstroCollection[]>([]);
React.useEffect(() => {
triggerLoading(true);
messageHandler.request<AstroCollection[]>(DashboardMessage.ssgGetAstroContentTypes).then((result) => {
triggerLoading(false);
setCollections(result);
});
}, []);
const generateContentType = (collection: AstroCollection) => {
triggerLoading(true);
messageHandler.request(DashboardMessage.ssgSetAstroContentTypes, {
collection
}).then((result) => {
triggerLoading(false);
});
}
if (!collections || collections.length === 0) {
return (
<div className='mt-1'>
{l10n.t(LocalizationKey.dashboardConfigurationAstroAstroContentTypesEmpty)}
</div>
);
}
return (
<div className='mt-1'>
<p>{l10n.t(LocalizationKey.dashboardConfigurationAstroAstroContentTypesDescription)}</p>
<div className='mt-2'>
{
collections.map((collection) => {
const ct = settings.contentTypes.find((c) => c.name === collection.name);
return (
<SelectItem
key={collection.name}
title={collection.name}
buttonTitle={collection.name}
isSelected={ct !== undefined}
onClick={() => generateContentType(collection)}
disabled={ct !== undefined} />
)
})
}
</div>
</div>
);
};
@@ -0,0 +1,50 @@
import * as React from 'react';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../../localization';
import { Settings } from '../../../models';
import { Folder } from './Folder';
import { Messenger } from '@estruyf/vscode/dist/client';
import { DashboardMessage } from '../../../DashboardMessage';
export interface IContentFoldersProps {
settings: Settings
triggerLoading: (isLoading: boolean) => void;
}
export const ContentFolders: React.FunctionComponent<IContentFoldersProps> = ({
settings
}: React.PropsWithChildren<IContentFoldersProps>) => {
const addFolder = (folder: string) => {
Messenger.send(DashboardMessage.addFolder, folder);
};
return (
<>
<p>
{l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersDescription)}
</p>
{settings?.dashboardState?.welcome?.contentFolders?.length > 0 && (
<div className="mt-4">
<div className="text-sm">{l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersLabel)}</div>
<div className="mt-1 space-y-1">
{settings?.dashboardState?.welcome?.contentFolders?.map((folder: string) => (
<Folder
key={folder}
folder={folder}
addFolder={addFolder}
wsFolder={settings.wsFolder}
folders={settings.contentFolders}
/>
))}
</div>
</div>
)}
<p className={`mt-4`}>
<b>{l10n.t(LocalizationKey.commonInformation)}</b>: {l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersInformationDescription)}.
</p>
</>
);
};
@@ -0,0 +1,34 @@
import * as React from 'react';
import { join } from 'path';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../../localization';
import { ContentFolder } from '../../../../models';
import { SelectItem } from '../../Steps/SelectItem';
export interface IFolderProps {
wsFolder: string;
folder: string;
folders: ContentFolder[];
addFolder: (folder: string) => void;
}
export const Folder: React.FunctionComponent<IFolderProps> = ({
wsFolder,
folder,
folders,
addFolder
}: React.PropsWithChildren<IFolderProps>) => {
const isAdded = React.useMemo(
() => folders.find((f) => f.path.toLowerCase() === join(wsFolder, folder).toLowerCase()),
[folder, folders, wsFolder]
);
return (
<SelectItem
title={folder}
buttonTitle={l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedButtonAddFolderTitle)}
isSelected={!!isAdded}
onClick={() => addFolder(folder)} />
);
};
@@ -31,6 +31,7 @@ import { Navigation } from './Navigation';
import { ProjectSwitcher } from './ProjectSwitcher';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
import { SettingsLink } from '../SettingsView/SettingsLink';
export interface IHeaderProps {
header?: React.ReactNode;
@@ -156,7 +157,11 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({
}`}>
<Tabs onNavigate={updateView} />
<ProjectSwitcher />
<div className='flex'>
<ProjectSwitcher />
<SettingsLink onNavigate={updateView} />
</div>
</div>
{location.pathname === routePaths.contents && (
@@ -1,6 +1,5 @@
import * as React from 'react';
import { useLocation } from 'react-router-dom';
import useThemeColors from '../../hooks/useThemeColors';
import { NavigationType } from '../../models';
export interface ITabProps {
@@ -14,7 +13,6 @@ export const Tab: React.FunctionComponent<ITabProps> = ({
children
}: React.PropsWithChildren<ITabProps>) => {
const location = useLocation();
const { getColors } = useThemeColors();
return (
<button
@@ -0,0 +1,30 @@
import * as React from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsSelector } from '../../state';
import { CogIcon } from '@heroicons/react/solid';
import { NavigationType } from '../../models';
export interface ISettingsLinkProps {
onNavigate: (navigationType: NavigationType) => void;
}
export const SettingsLink: React.FunctionComponent<ISettingsLinkProps> = ({
onNavigate
}: React.PropsWithChildren<ISettingsLinkProps>) => {
const settings = useRecoilValue(SettingsSelector);
if (!settings) {
return null;
}
return (
<button
className="flex items-center mr-4 hover:text-[var(--vscode-textLink-activeForeground)]"
title={`Settings`}
onClick={() => onNavigate(NavigationType.Settings)}
>
<CogIcon className="h-4 w-4" />
<span className='sr-only'>Settings</span>
</button>
);
};
@@ -0,0 +1,79 @@
import * as React from 'react';
import { PageLayout } from '../Layout';
import { SponsorMsg } from '../Layout/SponsorMsg';
import { useRecoilValue } from 'recoil';
import { SettingsSelector } from '../../state';
import { Spinner } from '../Common/Spinner';
import { AstroContentTypes } from '../Configuration/Astro/AstroContentTypes';
import { ContentFolders } from '../Configuration/Common/ContentFolders';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
export interface ISettingsViewProps { }
export const SettingsView: React.FunctionComponent<ISettingsViewProps> = (_: React.PropsWithChildren<ISettingsViewProps>) => {
const [loading, setLoading] = React.useState<boolean>(false);
const settings = useRecoilValue(SettingsSelector);
return (
<PageLayout>
{
loading && <Spinner />
}
{
settings && (
<div className='mx-auto max-w-7xl'>
<h1 className='text-3xl'>{l10n.t(LocalizationKey.commonSettings)}</h1>
<div className='divide-y divide-[var(--frontmatter-border)]'>
{
settings?.crntFramework === 'astro' && (
<div className='py-4'>
<h2 className='text-xl mb-2'>{l10n.t(LocalizationKey.settingsContentTypes)}</h2>
<AstroContentTypes
settings={settings}
triggerLoading={(isLoading) => setLoading(isLoading)} />
</div>
)
}
<div className='py-4'>
<h2 className='text-xl mb-2'>{l10n.t(LocalizationKey.settingsContentFolders)}</h2>
<ContentFolders
settings={settings}
triggerLoading={(isLoading) => setLoading(isLoading)} />
</div>
<div className='py-4 space-y-2'>
<h2 className='text-xl mb-2'>{l10n.t(LocalizationKey.settingsDiagnostic)}</h2>
<p>{l10n.t(LocalizationKey.settingsDiagnosticDescription)}</p>
<p>
<a
href={`command:frontMatter.diagnostics`}
title={l10n.t(LocalizationKey.settingsDiagnosticLink)}
className='text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]'
>
{l10n.t(LocalizationKey.settingsDiagnosticLink)}
</a>
</p>
</div>
</div>
</div>
)
}
<SponsorMsg
beta={settings?.beta}
version={settings?.versionInfo}
isBacker={settings?.isBacker}
/>
<img className='hidden' src="https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Ffrontmatter.codes%2Fmetrics%2Fdashboards&slug=settings" alt="Settings metrics" />
</PageLayout >
);
};
@@ -7,6 +7,7 @@ export interface ISelectItemProps {
icon?: "add" | "select";
buttonTitle: string;
isSelected: boolean;
disabled?: boolean;
onClick: () => void;
}
@@ -15,6 +16,7 @@ export const SelectItem: React.FunctionComponent<ISelectItemProps> = ({
icon = "select",
buttonTitle,
isSelected,
disabled,
onClick
}: React.PropsWithChildren<ISelectItemProps>) => {
return (
@@ -23,8 +25,9 @@ export const SelectItem: React.FunctionComponent<ISelectItemProps> = ({
>
<button
onClick={onClick}
className={`mr-2 flex gap-2 items-center hover:text-[var(--vscode-textLink-activeForeground)]`}
className={`mr-2 flex gap-2 items-center hover:text-[var(--vscode-textLink-activeForeground)] disabled:cursor-not-allowed`}
title={buttonTitle}
disabled={disabled}
>
{isSelected ? (
icon === "add" ? (
+4 -11
View File
@@ -1,6 +1,5 @@
import { CheckIcon } from '@heroicons/react/outline';
import * as React from 'react';
import useThemeColors from '../../hooks/useThemeColors';
import { Status } from '../../models/Status';
export interface IStepProps {
@@ -18,7 +17,6 @@ export const Step: React.FunctionComponent<IStepProps> = ({
showLine,
onClick
}: React.PropsWithChildren<IStepProps>) => {
const { getColors } = useThemeColors();
const renderChildren = () => {
return (
@@ -30,21 +28,17 @@ export const Step: React.FunctionComponent<IStepProps> = ({
}`}
>
<span
className={`h-2.5 w-2.5 bg-transparent rounded-full ${onClick ? getColors('group-hover:bg-gray-400', 'group-hover:bg-[var(--vscode-button-hoverBackground)]') : ''
className={`h-2.5 w-2.5 bg-transparent rounded-full ${onClick ? 'group-hover:bg-[var(--vscode-button-hoverBackground)]' : ''
}`}
/>
</div>
</div>
)}
{status === Status.Active && (
{(status === Status.Active || status === Status.Optional) && (
<div className="h-9 flex items-center" aria-hidden="true">
<div className={`relative z-10 w-8 h-8 flex items-center justify-center border-2 rounded-full bg-[var(--frontmatter-border)] border-[var(--frontmatter-border)] group-hover:text-[var(--vscode-button-foreground)] group-hover:border-[var(--vscode-button-hoverBackground)]`}>
<span className={`h-2.5 w-2.5 rounded-full ${getColors(
'bg-teal-600',
'bg-[var(--frontmatter-button-background)]'
)
}`} />
<span className={`h-2.5 w-2.5 rounded-full bg-[var(--frontmatter-button-background)]`} />
</div>
</div>
)}
@@ -71,8 +65,7 @@ export const Step: React.FunctionComponent<IStepProps> = ({
<>
{showLine ? (
<div
className={`-ml-px absolute mt-0.5 top-4 left-4 w-0.5 h-full ${status === Status.Completed ? getColors('bg-teal-600', 'bg-[var(--frontmatter-button-background)]') : getColors('bg-gray-300', 'bg-[var(--frontmatter-border)]')
}`}
className={`-ml-px absolute mt-0.5 top-4 left-4 w-0.5 h-full ${(status === Status.Completed || status === Status.Optional) ? 'bg-[var(--frontmatter-button-background)]' : 'bg-[var(--frontmatter-border)]'}`}
aria-hidden="true"
/>
) : null}
@@ -7,10 +7,9 @@ import { Step } from './Step';
import { useMemo, useState } from 'react';
import { Menu } from '@headlessui/react';
import { MenuItem } from '../Menu';
import { ContentFolder, Framework, StaticFolder, Template } from '../../../models';
import { Framework, StaticFolder, Template } from '../../../models';
import { ChevronDownIcon } from '@heroicons/react/outline';
import { FrameworkDetectors } from '../../../constants/FrameworkDetectors';
import { join } from 'path';
import useThemeColors from '../../hooks/useThemeColors';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
@@ -18,37 +17,13 @@ import { SelectItem } from './SelectItem';
import { Templates } from '../../../constants';
import { TemplateItem } from './TemplateItem';
import { Spinner } from '../Common/Spinner';
import { AstroContentTypes } from '../Configuration/Astro/AstroContentTypes';
import { ContentFolders } from '../Configuration/Common/ContentFolders';
export interface IStepsToGetStartedProps {
settings: Settings;
}
const Folder = ({
wsFolder,
folder,
folders,
addFolder
}: {
wsFolder: string;
folder: string;
folders: ContentFolder[];
addFolder: (folder: string) => void;
}) => {
const isAdded = useMemo(
() => folders.find((f) => f.path.toLowerCase() === join(wsFolder, folder).toLowerCase()),
[folder, folders, wsFolder]
);
return (
<SelectItem
title={folder}
buttonTitle={l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedButtonAddFolderTitle)}
isSelected={!!isAdded}
onClick={() => addFolder(folder)} />
);
};
export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps> = ({
settings
}: React.PropsWithChildren<IStepsToGetStartedProps>) => {
@@ -65,10 +40,6 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
Messenger.send(DashboardMessage.setFramework, framework);
};
const addFolder = (folder: string) => {
Messenger.send(DashboardMessage.addFolder, folder);
};
const addAssetFolder = (folder: string | StaticFolder) => {
Messenger.send(DashboardMessage.addAssetsFolder, folder);
}
@@ -207,13 +178,38 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
</div>
),
show: (crntTemplates || []).length > 0,
status: Status.Active,
status: Status.Optional,
},
{
id: `astro-content-types`,
name: l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedAstroContentTypesName),
description: (
<AstroContentTypes
settings={settings}
triggerLoading={(isLoading) => setLoading(isLoading)} />
),
show: settings.crntFramework === 'astro' || framework === 'astro',
status: Status.Optional
},
{
id: `welcome-content-folders`,
name: l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersName),
description: (
<ContentFolders
settings={settings}
triggerLoading={(isLoading) => setLoading(isLoading)} />
),
show: true,
status:
settings.contentFolders && settings.contentFolders.length > 0
? Status.Completed
: Status.NotStarted
},
{
id: `welcome-assets`,
name: l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedAssetsFolderName),
description: (
<div className='mt-4'>
<div className='mt-1'>
<div className="text-sm">{l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedAssetsFolderDescription)}</div>
<div className="mt-1 space-y-1">
<SelectItem
@@ -239,43 +235,6 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
show: settings.crntFramework === 'astro' || framework === 'astro',
status: settings.initialized && settings.staticFolder && settings.staticFolder !== "/" ? Status.Completed : Status.NotStarted,
},
{
id: `welcome-content-folders`,
name: l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersName),
description: (
<>
<p>
{l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersDescription)}
</p>
{settings?.dashboardState?.welcome?.contentFolders?.length > 0 && (
<div className="mt-4">
<div className="text-sm">{l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersLabel)}</div>
<div className="mt-1 space-y-1">
{settings?.dashboardState?.welcome?.contentFolders?.map((folder: string) => (
<Folder
key={folder}
folder={folder}
addFolder={addFolder}
wsFolder={settings.wsFolder}
folders={settings.contentFolders}
/>
))}
</div>
</div>
)}
<p className={`mt-4 ${getColors('text-vulcan-300 dark:text-gray-400', '')}`}>
<b>{l10n.t(LocalizationKey.commonInformation)}</b>: {l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedContentFoldersInformationDescription)}.
</p>
</>
),
show: true,
status:
settings.contentFolders && settings.contentFolders.length > 0
? Status.Completed
: Status.NotStarted
},
{
id: `welcome-import`,
name: l10n.t(LocalizationKey.dashboardStepsStepsToGetStartedTagsName),
+2 -1
View File
@@ -45,7 +45,8 @@ export const routePaths: { [name: string]: string } = {
media: '/media',
snippets: '/snippets',
data: '/data',
taxonomy: '/taxonomy'
taxonomy: '/taxonomy',
settings: '/settings',
};
const preserveColor = (color: string | undefined) => {
@@ -4,5 +4,6 @@ export enum NavigationType {
Media = 'media',
Data = 'data',
Snippets = 'snippets',
Taxonomy = 'taxonomy'
Taxonomy = 'taxonomy',
Settings = 'settings'
}
+2 -1
View File
@@ -1,5 +1,6 @@
export enum Status {
Active = 1,
Completed,
NotStarted
NotStarted,
Optional
}
+31 -26
View File
@@ -626,36 +626,41 @@ export class Settings {
}
// Check if there is a dynamic config file and use it to update the global config
const dynamicConfigPath =
Settings.globalConfig[`${CONFIG_KEY}.${SETTING_CONFIG_DYNAMIC_FILE_PATH}`];
if (dynamicConfigPath) {
try {
await window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `${EXTENSION_NAME}: Reading dynamic config file...`
},
async () => {
const absFilePath = Folders.getAbsFilePath(dynamicConfigPath);
Logger.info(`Reading dynamic config file: ${absFilePath}`);
if (absFilePath) {
if (await existsAsync(absFilePath)) {
const configFunction = require(absFilePath);
const dynamicConfig = await configFunction(
Object.assign({}, Settings.globalConfig)
);
if (
Settings.globalConfig &&
Settings.globalConfig[`${CONFIG_KEY}.${SETTING_CONFIG_DYNAMIC_FILE_PATH}`]
) {
const dynamicConfigPath =
Settings.globalConfig[`${CONFIG_KEY}.${SETTING_CONFIG_DYNAMIC_FILE_PATH}`];
if (dynamicConfigPath) {
try {
await window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `${EXTENSION_NAME}: Reading dynamic config file...`
},
async () => {
const absFilePath = Folders.getAbsFilePath(dynamicConfigPath);
Logger.info(`Reading dynamic config file: ${absFilePath}`);
if (absFilePath) {
if (await existsAsync(absFilePath)) {
const configFunction = require(absFilePath);
const dynamicConfig = await configFunction(
Object.assign({}, Settings.globalConfig)
);
if (dynamicConfig) {
Settings.globalConfig = dynamicConfig;
Logger.info(`Dynamic config file loaded`);
if (dynamicConfig) {
Settings.globalConfig = dynamicConfig;
Logger.info(`Dynamic config file loaded`);
}
}
}
}
}
);
} catch (e) {
Logger.error(`Error reading dynamic config file: ${dynamicConfigPath}`);
Logger.error((e as Error).message);
);
} catch (e) {
Logger.error(`Error reading dynamic config file: ${dynamicConfigPath}`);
Logger.error((e as Error).message);
}
}
}
} catch (e) {
+288
View File
@@ -0,0 +1,288 @@
import { Uri, workspace } from 'vscode';
import { DashboardMessage } from '../../dashboardWebView/DashboardMessage';
import { AstroCollection, AstroField, ContentType, Field, PostMessageData } from '../../models';
import { BaseListener } from './BaseListener';
import { exec } from 'child_process';
import { Extension, Logger, Settings } from '../../helpers';
import { Folders } from '../../commands';
import { SETTING_TAXONOMY_CONTENT_TYPES, SsgScripts } from '../../constants';
import { SettingsListener } from './SettingsListener';
import { Terminal } from '../../services';
export class SsgListener extends BaseListener {
/**
* Process the messages for the dashboard views
* @param msg
*/
public static process(msg: PostMessageData) {
super.process(msg);
switch (msg.command) {
case DashboardMessage.ssgGetAstroContentTypes:
SsgListener.getAstroContentTypes(msg);
break;
case DashboardMessage.ssgSetAstroContentTypes:
SsgListener.ssgSetAstroContentTypes(msg);
break;
}
}
/**
* Creata a content type from the Astro content collection
* @param param0
* @returns
*/
private static async ssgSetAstroContentTypes({ command, requestId, payload }: PostMessageData) {
if (!command || !requestId || !payload) {
return;
}
const { collection } = payload as { collection: AstroCollection };
const contentType: ContentType = {
name: collection.name,
filePrefix: undefined,
previewPath: undefined,
pageBundle: false,
clearEmpty: true,
fields: []
};
for (const field of collection.fields) {
const ctField = SsgListener.getCtFieldForAstroField(field, collection.fields);
if (ctField) {
contentType.fields.push(ctField);
}
}
const contentTypes = Settings.get<ContentType[]>(SETTING_TAXONOMY_CONTENT_TYPES) || [];
if (contentTypes.find((ct) => ct.name === collection.name)) {
SsgListener.sendRequest(command as any, requestId, {});
SettingsListener.getSettings(true);
return;
}
try {
// Check if the content folder exists
const folderName = `/src/content/${name}`;
const folderUri = Uri.joinPath(Folders.getWorkspaceFolder()!, folderName);
await workspace.fs.readDirectory(folderUri);
await Folders.register({
title: collection.name,
path: folderUri,
contentType: [collection.name]
});
contentType.previewPath = `'${name}'`;
} catch (e) {
// Something failed, so it seems the folder does not exist
}
contentTypes.push(contentType);
await Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, contentTypes, true);
SsgListener.sendRequest(command as any, requestId, {});
SettingsListener.getSettings(true);
}
/**
* Get the Astro content types from the local project
* @param command
* @param requestId
*/
private static async getAstroContentTypes({ command, requestId }: PostMessageData) {
if (!command || !requestId) {
return;
}
// https://github.com/withastro/astro/blob/defab70cb2a0c67d5e9153542490d2749046b151/packages/astro/src/content/utils.ts#L450
const contentConfig = await workspace.findFiles(`**/src/content/config.*`);
if (contentConfig.length === 0) {
SsgListener.sendRequest(command as any, requestId, {});
return;
}
const contentConfigFile = contentConfig[0];
if (
!contentConfigFile.fsPath.endsWith('.js') &&
!contentConfigFile.fsPath.endsWith('.mjs') &&
!contentConfigFile.fsPath.endsWith('.ts') &&
!contentConfigFile.fsPath.endsWith('.mts')
) {
SsgListener.sendRequest(command as any, requestId, {});
return;
}
const scriptPath = Uri.joinPath(
Extension.getInstance().extensionPath,
SsgScripts.folder,
SsgScripts.astroContentCollection
);
const wsFolder = Folders.getWorkspaceFolder();
if (!wsFolder) {
SsgListener.sendRequest(command as any, requestId, {});
return;
}
const tempLocation = Uri.joinPath(wsFolder, '/.frontmatter/temp');
const tempScriptPath = Uri.joinPath(tempLocation, SsgScripts.astroContentCollection);
await workspace.fs.createDirectory(tempLocation);
workspace.fs.copy(scriptPath, tempScriptPath, { overwrite: true });
const fullScript = `node "${tempScriptPath.fsPath}" "${contentConfigFile.fsPath}"`;
try {
const result: string = await SsgListener.executeScript(fullScript, wsFolder?.fsPath || '');
if (result) {
let collections: AstroCollection[] = JSON.parse(result);
collections = collections.filter((c) => c.type === 'content');
SsgListener.sendRequest(command as any, requestId, collections);
}
} catch (error) {
Logger.error((error as Error).message);
SsgListener.sendRequest(command as any, requestId, {});
return;
} finally {
await workspace.fs.delete(tempLocation, { recursive: true, useTrash: false });
}
}
/**
* Generate the ContentType field from the Astro field
* @param field
* @param collection
* @returns
*/
private static getCtFieldForAstroField(
field: AstroField,
collection: AstroField[]
): Field | undefined {
let ctField: Field | undefined = undefined;
const hasImageField = collection.find((f) => f.type === 'image');
switch (field.type) {
case 'email':
case 'url':
case 'ZodString':
ctField = {
name: field.name,
type: 'string',
single: true
} as Field;
break;
case 'ZodNumber':
ctField = {
name: field.name,
type: 'number'
} as Field;
break;
case 'ZodBoolean':
ctField = {
name: field.name,
type: 'boolean'
} as Field;
break;
case 'ZodArray':
ctField = {
name: field.name,
type: 'list'
} as Field;
break;
case 'ZodEnum':
ctField = {
name: field.name,
type: 'choice',
choices: field.options || []
} as Field;
break;
case 'ZodDate':
ctField = {
name: field.name,
type: 'datetime',
default: '{{now}}'
} as Field;
break;
case 'image':
ctField = {
name: field.name,
type: 'image'
} as Field;
break;
case 'ZodObject':
ctField = {
name: field.name,
type: 'fields'
} as Field;
break;
}
if (field.type === 'ZodObject' && field.fields) {
ctField.fields = [];
for (const subField of field.fields) {
const ctSubField = SsgListener.getCtFieldForAstroField(subField, collection);
if (ctSubField) {
ctField.fields.push(ctSubField);
}
}
}
if (ctField && field.required) {
ctField.required = field.required;
}
if (ctField && field.defaultValue) {
ctField.default = field.defaultValue;
}
if (ctField && field.description) {
ctField.description = field.description;
}
if (ctField && field.type === 'image' && !hasImageField) {
ctField.isPreviewImage = true;
}
return ctField;
}
/**
* Execute a script
* @param fullScript
* @param wsFolder
* @returns
*/
private static executeScript(fullScript: string, wsFolder: string): Promise<string> {
return new Promise((resolve, reject) => {
Logger.info(`Executing script: ${fullScript}`);
const shellPath: string | { path: string } | undefined = Terminal.shell;
exec(
fullScript,
{
cwd: wsFolder,
shell: shellPath
},
(error, stdout) => {
if (error) {
reject(error);
return;
}
if (stdout && stdout.endsWith(`\n`)) {
// Remove empty line at the end of the string
stdout = stdout.slice(0, -1);
}
resolve(stdout);
}
);
});
}
}
+1
View File
@@ -10,3 +10,4 @@ export * from './TelemetryListener';
export * from './TaxonomyListener';
export * from './LogListener';
export * from './LocalizationListener';
export * from './SsgListener';
+36
View File
@@ -95,6 +95,30 @@ export enum LocalizationKey {
* Open on website
*/
commonOpenOnWebsite = 'common.openOnWebsite',
/**
* Settings
*/
commonSettings = 'common.settings',
/**
* Content types
*/
settingsContentTypes = 'settings.contentTypes',
/**
* Content folders
*/
settingsContentFolders = 'settings.contentFolders',
/**
* Diagnostic
*/
settingsDiagnostic = 'settings.diagnostic',
/**
* You can run the diagnostics to check the whole Front Matter CMS configuration.
*/
settingsDiagnosticDescription = 'settings.diagnostic.description',
/**
* Run full diagnostics
*/
settingsDiagnosticLink = 'settings.diagnostic.link',
/**
* Developer mode
*/
@@ -807,6 +831,10 @@ export enum LocalizationKey {
* Select a template to prefill the frontmatter.json file with the recommended settings.
*/
dashboardStepsStepsToGetStartedTemplateDescription = 'dashboard.steps.stepsToGetStarted.template.description',
/**
* Create Content-Types for your Astro Content Collections
*/
dashboardStepsStepsToGetStartedAstroContentTypesName = 'dashboard.steps.stepsToGetStarted.astroContentTypes.name',
/**
* Add {0} to taxonomy settings
*/
@@ -935,6 +963,14 @@ export enum LocalizationKey {
* Do you want to remap the metadata of unmapped files?
*/
dashboardMediaDetailsSlideOverUnmappedDescription = 'dashboard.media.detailsSlideOver.unmapped.description',
/**
* No Astro Content Collections found.
*/
dashboardConfigurationAstroAstroContentTypesEmpty = 'dashboard.configuration.astro.astroContentTypes.empty',
/**
* The following Astro Content Collections and can be used to generate a content-type.
*/
dashboardConfigurationAstroAstroContentTypesDescription = 'dashboard.configuration.astro.astroContentTypes.description',
/**
* Content-type
*/
+25
View File
@@ -0,0 +1,25 @@
export interface AstroCollection {
name: string;
type: 'content' | 'data';
fields: AstroField[];
}
export interface AstroField {
name: string;
type:
| 'ZodString'
| 'ZodNumber'
| 'ZodBoolean'
| 'ZodArray'
| 'ZodEnum'
| 'ZodDate'
| 'ZodObject'
| 'email'
| 'url'
| 'image';
required: boolean;
defaultValue?: string;
options?: string[];
description?: string;
fields?: AstroField[];
}
+5 -5
View File
@@ -1,5 +1,5 @@
export interface PostMessageData {
command: string;
payload: any;
requestId?: string
}
export interface PostMessageData {
command: string;
payload: any;
requestId?: string;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './AstroCollections';
export * from './BaseFieldProps';
export * from './BlockFieldData';
export * from './Choice';
+66
View File
@@ -0,0 +1,66 @@
import { workspace } from 'vscode';
import * as os from 'os';
interface ShellSetting {
path: string;
}
export class Terminal {
/**
* Return the shell path for the current platform
*/
public static get shell() {
const shell: string | { path: string } | undefined = Terminal.getShellPath();
let shellPath: string | undefined = undefined;
if (typeof shell !== 'string' && !!shell) {
shellPath = shell.path;
} else {
shellPath = shell || undefined;
}
return shellPath;
}
/**
* Retrieve the automation profile for the current platform
* @returns
*/
private static getShellPath(): string | ShellSetting | undefined {
const platform = Terminal.getPlatform();
const terminalSettings = workspace.getConfiguration('terminal');
const automationProfile = terminalSettings.get<string | ShellSetting>(
`integrated.automationProfile.${platform}`
);
if (!!automationProfile) {
return automationProfile;
}
const defaultProfile = terminalSettings.get<string>(`integrated.defaultProfile.${platform}`);
const profiles = terminalSettings.get<{ [prop: string]: ShellSetting }>(
`integrated.profiles.${platform}`
);
if (defaultProfile && profiles && profiles[defaultProfile]) {
return profiles[defaultProfile];
}
return terminalSettings.get(`integrated.shell.${platform}`);
}
/**
* Get the current platform
* @returns
*/
private static getPlatform = (): 'windows' | 'linux' | 'osx' => {
const platform = os.platform();
if (platform === 'win32') {
return 'windows';
} else if (platform === 'darwin') {
return 'osx';
}
return 'linux';
};
}
+5
View File
@@ -0,0 +1,5 @@
export * from './Credentials';
export * from './ModeSwitch';
export * from './PagesParser';
export * from './SponsorAI';
export * from './Terminal';
+217
View File
@@ -0,0 +1,217 @@
import { createServer } from "vite";
import zod from "zod";
const {
ZodDefault,
ZodObject,
ZodOptional,
ZodString,
ZodEffects,
ZodEnum
} = zod;
/**
* Process the Zod field
* @param {ZodTypeAny} field
* @param {string} defaultValue
* @returns
*/
function getField(field, defaultValue = undefined) {
let isOptional = false;
// Handle various type transformations and assignments
if (field instanceof ZodOptional) {
isOptional = true;
const type = field.unwrap();
return getField(type, isOptional, defaultValue)
}
if (field instanceof ZodDefault) {
// https://github.com/colinhacks/zod/blob/master/README.md#default
isOptional = true;
defaultValue = field.parse(undefined);
// https://github.com/sachinraja/zod-to-ts/blob/main/src/index.ts
const type = field._def.innerType;
return getField(type, isOptional, defaultValue)
}
return {
type: field,
isOptional,
defaultValue
}
}
/**
* Generate the field information
* @param {string} name
* @param {ZodTypeAny} type
* @returns
*/
function generateFieldInfo(name, type) {
let description = type.description;
let defaultValue = undefined;
const {
type: fieldType,
isOptional: isFieldOptional,
defaultValue: fieldDefaultValue
} = getField(type, defaultValue);
const fieldInfo = {
name: name,
description: description,
defaultValue: fieldDefaultValue,
type: fieldType._def.typeName,
required: !isFieldOptional,
};
if (fieldType instanceof ZodObject) {
const subFields = extractFieldInfoFromShape(fieldType);
fieldInfo.fields = subFields;
}
if (fieldType instanceof ZodEffects) {
fieldInfo.type = fieldType.sourceType().fmFieldType;
}
if (fieldType instanceof ZodEnum) {
fieldInfo.options = fieldType.options;
}
if (fieldType instanceof ZodString) {
// https://github.com/StefanTerdell/zod-to-json-schema/blob/master/src/parsers/string.ts#L45
if (fieldType._def.checks && fieldType._def.checks.length > 0) {
const check = fieldType._def.checks.pop();
fieldInfo.type = check.kind;
}
}
return fieldInfo;
}
/**
* Parse the scheme into an array of fields
* @param {ZodTypeAny} type
* @returns
*/
function extractFieldInfoFromShape(type) {
if (type instanceof ZodOptional) {
type = type.unwrap();
}
if (!(type instanceof ZodObject)) {
// Return an empty array if the type is not of the expected type
return [];
}
// Iterate through the shape properties
// https://github.com/sachinraja/zod-to-ts/blob/1389b33557bcca8a02da66cd5c48efbe7579720c/src/index.ts#L134
const properties = Object.entries(type._def.shape());
const fieldInfoList = properties.map(([fieldName, fieldShape]) => {
return generateFieldInfo(fieldName, fieldShape);
});
return fieldInfoList;
}
/**
* Process each content collection
* @param {*} collections
* @returns
*/
function processCollection(collections) {
if (!Array.isArray(collections)) {
return [];
}
return collections.map(([name, collection]) => {
const schema = typeof collection.schema === "function" ? collection.schema({
image() {
const field = zod.string();
field.fmFieldType = "image";
return field;
}
}) : collection.schema;
return {
name,
type: collection.type || "content",
fields: extractFieldInfoFromShape(schema),
};
});
}
/**
* More info: https://vitejs.dev/guide/api-plugin.html#virtual-modules-convention
* @returns
*/
const astroContentModulePlugin = () => {
const astroContent = "astro:content";
const astroContentMarker = `\0${astroContent}`;
return {
name: "astro-content-collections",
resolveId(importee) {
if (importee === astroContent) {
return astroContentMarker;
}
},
load(id) {
if (id === astroContentMarker) {
// https://github.com/withastro/astro/blob/defab70cb2a0c67d5e9153542490d2749046b151/packages/astro/content-module.template.mjs#L12
return `
export { z } from 'astro/zod';
// Reference: /node_modules/astro/content-module.template.mjs
export function defineCollection(config) {
if (!config.type) config.type = 'content';
return config;
};
`;
}
}
}
};
/**
* Start of the Astro Collections script
*/
(async () => {
const configPath = process.argv[2];
if (!configPath || typeof configPath !== "string") {
console.log("No config path provided");
process.exit(1);
}
// https://vitejs.dev/guide/ssr.html#setting-up-the-dev-server
// https://github.com/withastro/astro/blob/defab70cb2a0c67d5e9153542490d2749046b151/packages/astro/src/core/config/vite-load.ts#L8
const server = await createServer({
server: {
middlewareMode: true,
hmr: false
},
optimizeDeps: {
disabled: true,
},
appType: "custom",
plugins: [astroContentModulePlugin()]
});
try {
// https://github.dev/withastro/astro/blob/defab70cb2a0c67d5e9153542490d2749046b151/packages/astro/src/content/utils.ts#L334
const contentModule = await server.ssrLoadModule(configPath)
const collections = Object.entries(contentModule.collections ?? {});
const processedCollections = processCollection(collections);
console.log(JSON.stringify(processedCollections));
process.exit(0);
} catch (error) {
console.log(error.message);
process.exit(1);
} finally {
await server.close();
}
})();