diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a789ea188..86c459cec 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,9 +8,6 @@ on: pull_request: branches: - main - schedule: - # Run at 6 AM UTC every Monday - - cron: '0 6 * * 1' jobs: analyze: diff --git a/.vscode/settings.json b/.vscode/settings.json index 7af45fa55..fd674104a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,6 +41,7 @@ "gomodule", "GSAP", "hocho", + "htmlcsstoimage", "Hudi", "interrobang", "izakaya", @@ -121,6 +122,7 @@ "Veeam", "vercel", "virt", + "vite", "vmax", "vtbot", "WHATWG", diff --git a/_TODO.md b/_TODO.md index 25a24b4fe..20cd25da0 100644 --- a/_TODO.md +++ b/_TODO.md @@ -410,6 +410,27 @@ Allows any Mastodon instance to discover your Mastodon profile directly from you ``` +## Refactor social neworks in Authors collection to Contact collection format + +The contact data collection uses an array of social networks, with keys: + +``` +{ + network: z.string(), + name: z.string(), + url: z.string().url(), + order: z.number(), +} +``` + +The authors collection is using named entries under a "social" property, like "twitter", "github", etc. This task is to refactor that to use an array like contact data collection. We also need to add a color for the social network icon, or some other approach to setting the color of it while enabling theming. + +We should also make sure the avatar key in the authors collection is being output as a responsive image tag. + +## Add Google Maps screenshot (or maps embed) to Contact Page + +src/assets/images/map.webp + ## Display a system font until font files load (Lighthouse improvements) Display a system font until font files load to improve FCP (First Contentful Paint) with `font-display: swap`. Need to make sure that web font doesn't render larger or smaller than the system font fallback to avoid CLS (Cumulative Layout Shift) issues. diff --git a/public/assets/images/placeholder-article.jpg b/public/assets/images/placeholder-article.jpg deleted file mode 100644 index 94e784f6b..000000000 Binary files a/public/assets/images/placeholder-article.jpg and /dev/null differ diff --git a/src/assets/images/typescript-shop-cover-opt.png b/src/assets/images/typescript-downloadable-icon.png similarity index 100% rename from src/assets/images/typescript-shop-cover-opt.png rename to src/assets/images/typescript-downloadable-icon.png diff --git a/src/assets/images/typescript-panel.avif b/src/assets/images/typescript-panel.avif deleted file mode 100644 index b9b5b39aa..000000000 Binary files a/src/assets/images/typescript-panel.avif and /dev/null differ diff --git a/src/components/Carousel/client/__fixtures__/collection.fixture.ts b/src/components/Carousel/client/__fixtures__/collection.fixture.ts index 1c7c1a23f..579173ea0 100644 --- a/src/components/Carousel/client/__fixtures__/collection.fixture.ts +++ b/src/components/Carousel/client/__fixtures__/collection.fixture.ts @@ -1,12 +1,22 @@ +import type { ImageMetadata } from 'astro' + +const createCover = (src: string, format: ImageMetadata['format'] = 'jpeg'): ImageMetadata => ({ + src, + width: 1200, + height: 675, + format, +}) + const sampleCollection = [ { id: 'article-one', data: { title: 'Article One', description: 'First test entry', - publishDate: '2024-01-01', + publishDate: new Date('2024-01-01'), featured: true, - icon: '/icons/one.svg', + cover: createCover('/_astro/article-one.jpg'), + coverAlt: 'Cover image for Article One', }, }, { @@ -14,8 +24,10 @@ const sampleCollection = [ data: { title: 'Article Two', description: 'Second test entry', - publishDate: '2024-02-01', + publishDate: new Date('2024-02-01'), featured: true, + cover: createCover('/_astro/article-two.jpg'), + coverAlt: 'Cover image for Article Two', }, }, { @@ -23,8 +35,10 @@ const sampleCollection = [ data: { title: 'Article Three', description: 'Third test entry', - publishDate: '2024-03-01', + publishDate: new Date('2024-03-01'), featured: false, + cover: createCover('/_astro/article-three.jpg'), + coverAlt: 'Cover image for Article Three', }, }, { @@ -32,8 +46,10 @@ const sampleCollection = [ data: { title: 'Article Four', description: 'Fourth test entry', - publishDate: '2024-04-01', + publishDate: new Date('2024-04-01'), featured: false, + cover: createCover('/_astro/article-four.jpg'), + coverAlt: 'Cover image for Article Four', }, }, ] diff --git a/src/components/Carousel/client/__tests__/index.spec.ts b/src/components/Carousel/client/__tests__/index.spec.ts index 1bae53ffa..c593062ce 100644 --- a/src/components/Carousel/client/__tests__/index.spec.ts +++ b/src/components/Carousel/client/__tests__/index.spec.ts @@ -142,7 +142,8 @@ describe('Carousel component (server output)', () => { const cardWithIcon = root.querySelector('[data-carousel-slide] img') expect(cardWithIcon).toBeTruthy() - expect(cardWithIcon?.getAttribute('src')).toBe('/icons/one.svg') + expect(cardWithIcon?.getAttribute('src')).toMatch(/^\/_image\?href=/) + expect(cardWithIcon?.getAttribute('alt')).toBe('Cover image for Article One') }) }) diff --git a/src/components/Carousel/index.astro b/src/components/Carousel/index.astro index 629619e27..951f65913 100644 --- a/src/components/Carousel/index.astro +++ b/src/components/Carousel/index.astro @@ -1,6 +1,10 @@ --- -// Generic Carousel component for displaying content - both featured and suggested +/** + * Generic Carousel component for displaying content - both featured and suggested + */ +import { Picture } from 'astro:assets' import { getCollection } from 'astro:content' + import { type CollectionSlug, type CarouselVariant, @@ -52,11 +56,19 @@ const items = prepareItems(allItems, variant, currentSlug, limit) href={`/${type}/${item.id}`} class="block h-full bg-bg rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden border border-border hover:border-primary transform hover:-translate-y-2" > - {'icon' in item.data && item.data.icon && ( -
-
- -
+ {item.data.cover && ( +
+
)}
diff --git a/src/components/Forms/Download/README.md b/src/components/Forms/Download/README.md deleted file mode 100644 index ad36af06d..000000000 --- a/src/components/Forms/Download/README.md +++ /dev/null @@ -1,316 +0,0 @@ -# Downloads Component - -A gated content system for offering downloadable resources (whitepapers, ebooks, guides) in exchange for user contact information. - -## Overview - -The Downloads content type provides a complete lead generation system with: - -- **Content Management**: Markdown-based content with frontmatter metadata -- **Gated Access**: Contact form required before downloads -- **Lead Capture**: First name, last name, work email, job title, company name -- **Responsive Design**: Mobile-friendly form and layout -- **Browser Autofill**: Uses standard HTML autocomplete attributes -- **API Integration**: Ready for CRM/marketing automation integration - -## Architecture - -### Content Structure - -```text -src/content/downloads/ -├── resource-name-1/ -│ ├── index.md # Main content -│ └── cover.jpg # Cover image (optional) -├── resource-name-2/ -│ └── index.md -``` - -### Components - -1. **DownloadForm** (`src/components/Forms/Download/index.astro`) - - Standalone form component - - Client-side validation - - API submission handling - - Success/error messaging - - Autofill support - -2. **Download Template** (`src/pages/downloads/[...slug].astro`) - - Individual download page layout - - Resource metadata display - - Content rendering - - Integrated form - -3. **API Endpoint** (`src/pages/api/downloads/submit.ts`) - - Form submission handler - - Validation - - Integration point for external services - -## Content Schema - -```typescript -{ - title: string // Resource title - description: string // SEO and display description - author: reference('authors') // Optional author reference - tags: string[] // Topic tags - image: { - src: string // Cover image URL - alt: string // Image alt text - } - publishDate: Date // Publication date - isDraft: boolean // Draft status (default: false) - featured: boolean // Featured flag (default: false) - fileType: string // 'PDF' | 'eBook' | 'Whitepaper' | 'Guide' | 'Report' | 'Template' - fileSize?: string // e.g., "2.3 MB" - pages?: number // Page count - readingTime?: string // e.g., "30 min read" -} -``` - -## Usage - -### Creating a New Download - -1. Create a new directory in `src/content/downloads/`: - -```bash -mkdir src/content/downloads/my-resource -``` - -1. Create `index.md` with frontmatter: - -```markdown ---- -title: "My Resource Title" -description: "A compelling description of what readers will learn" -author: "kevin-brown" -tags: ["topic1", "topic2"] -image: - src: "/assets/images/downloads/my-resource.jpg" - alt: "Resource cover description" -publishDate: 2025-01-15 -isDraft: false -featured: true -fileType: "Whitepaper" -fileSize: "2.3 MB" -pages: 24 -readingTime: "30 min read" ---- - -## Content Here - -Your markdown content... -``` - -1. Add cover image (optional): - -```bash -cp cover.jpg src/content/downloads/my-resource/ -``` - -### Using the DownloadForm Component - -```astro ---- -import DownloadForm from '@components/Forms/Download/index.astro' ---- - - -``` - -**Props:** - -- `title` (required): Display name of the resource -- `fileType` (optional): Type label (default: "resource") -- `downloadUrl` (optional): URL to download after form submission -- `className` (optional): Additional CSS classes - -## Form Fields - -All form fields use HTML5 autocomplete attributes for browser autofill: - -| Field | Type | Required | Autocomplete | -|-------|------|----------|--------------| -| First Name | text | Yes | `given-name` | -| Last Name | text | Yes | `family-name` | -| Work Email | email | Yes | `email` | -| Job Title | text | Yes | `organization-title` | -| Company Name | text | Yes | `organization` | - -## API Integration - -### Current Implementation - -The API endpoint at `/api/downloads/submit` currently logs submissions to the console. To integrate with your CRM or marketing automation platform: - -1. Edit `src/pages/api/downloads/submit.ts` -2. Add integration code in the TODO sections: - -```typescript -// TODO: Integrate with email service (e.g., SendGrid, Mailchimp, HubSpot) -// Example: -await sendToMailchimp({ - email: data.workEmail, - firstName: data.firstName, - lastName: data.lastName, - // ... -}) - -// TODO: Store submission in database or CRM -// Example: -await hubspot.contacts.create({ - properties: { - email: data.workEmail, - firstname: data.firstName, - lastname: data.lastName, - jobtitle: data.jobTitle, - company: data.companyName, - } -}) -``` - -### Integration Examples - -#### HubSpot - -```typescript -import { Client } from '@hubspot/api-client' -import { HUBSPOT_TOKEN } from 'astro:env/client' - -const hubspot = new Client({ accessToken: HUBSPOT_TOKEN }) - -await hubspot.crm.contacts.basicApi.create({ - properties: { - email: data.workEmail, - firstname: data.firstName, - lastname: data.lastName, - jobtitle: data.jobTitle, - company: data.companyName, - } -}) -``` - -#### Mailchimp - -```typescript -import mailchimp from '@mailchimp/mailchimp_marketing' -import { MAILCHIMP_API_KEY, MAILCHIMP_SERVER_PREFIX } from 'astro:env/client' - -mailchimp.setConfig({ - apiKey: MAILCHIMP_API_KEY, - server: MAILCHIMP_SERVER_PREFIX, -}) - -await mailchimp.lists.addListMember(LIST_ID, { - email_address: data.workEmail, - status: 'subscribed', - merge_fields: { - FNAME: data.firstName, - LNAME: data.lastName, - JOBTITLE: data.jobTitle, - COMPANY: data.companyName, - } -}) -``` - -#### SendGrid - -```typescript -import sgMail from '@sendgrid/mail' -import { SENDGRID_API_KEY } from 'astro:env/client' - -sgMail.setApiKey(SENDGRID_API_KEY) - -await sgMail.send({ - to: 'leads@yourcompany.com', - from: 'downloads@yourcompany.com', - subject: 'New Download Lead', - text: `New lead from ${data.firstName} ${data.lastName} at ${data.companyName}`, - html: `Contact Details:
- Name: ${data.firstName} ${data.lastName}
- Email: ${data.workEmail}
- Job Title: ${data.jobTitle}
- Company: ${data.companyName}`, -}) -``` - -## Analytics - -Track form interactions by adding event listeners: - -```javascript -document.getElementById('downloadForm').addEventListener('submit', () => { - // Track with your analytics platform - analytics.track('Download Form Submitted', { - resource: 'Resource Name', - // ... - }) -}) -``` - -## SEO Considerations - -Each download page includes: - -- Structured metadata (title, description, image) -- Author attribution -- Publication date -- Topic tags -- Proper heading hierarchy -- Alt text for images - -## Future Enhancements - -Potential improvements to consider: - -1. **Progress Indicator**: Multi-step form for longer forms -2. **Social Proof**: Show download count or testimonials -3. **Email Verification**: Send download link via email -4. **A/B Testing**: Test form variations for conversion optimization -5. **GDPR Compliance**: Add explicit consent checkboxes for EU users -6. **Download Tracking**: Track who downloads what and when -7. **Personalization**: Return visitors see simplified form -8. **Lead Scoring**: Assign scores based on company domain, job title -9. **Conditional Fields**: Show/hide fields based on previous answers -10. **File Hosting**: Integrate with secure file storage (AWS S3, etc.) - -## Testing - -Test the download flow: - -1. Navigate to `/downloads/[slug]` -2. Fill out the form with valid data -3. Submit and verify: - - Success message displays - - Form resets - - Download initiates (if `downloadUrl` provided) - - API endpoint logs submission - -## Accessibility - -The form includes: - -- Proper label associations -- Required field indicators -- ARIA live regions for status messages -- Keyboard navigation support -- Focus management -- Error messaging - -## Browser Support - -Autofill works in: - -- Chrome 14+ -- Firefox 4+ -- Safari 5.1+ -- Edge 12+ - -## License - -Part of the Webstack Builders corporate website. diff --git a/src/components/Head/Meta.astro b/src/components/Head/Meta.astro index d8e7e9dc8..f58aa4dc6 100644 --- a/src/components/Head/Meta.astro +++ b/src/components/Head/Meta.astro @@ -7,31 +7,40 @@ import Social from '@components/Head/Social.astro' import StructuredData from '@components/Head/StructuredData.astro' export interface Props { - pageTitle: string - path: string + /** Author name */ + author?: string + /** Content type for OpenGraph metadata */ + contentType?: 'article' | 'website' + /** Meta description */ description?: string image?: string - contentType?: 'article' | 'website' - publishDate?: Date modifiedDate?: Date - author?: string + /** Whether search engines should ignore the page, default: false */ + noindex?: boolean + /** Meta page title displayed in the browser tab and window title bar */ + pageTitle: string + /** URL path component e.g. 'articles/my-article' */ + path: string + /** Publication date for articles */ + publishDate?: Date + /** Specifies the main, high-level category or theme of the article */ section?: string + /** Content tags */ tags?: string[] - noindex?: boolean } const { - pageTitle, - path, + author, + contentType, description, image, - contentType, - publishDate, modifiedDate, - author, + noindex = false, + pageTitle, + path, + publishDate, section, tags, - noindex = false, } = Astro.props const { slug, defaultTheme } = getMetaThemeData(path) diff --git a/src/components/Head/Seo.astro b/src/components/Head/Seo.astro index 2a8e7dc0e..c06c0cd07 100644 --- a/src/components/Head/Seo.astro +++ b/src/components/Head/Seo.astro @@ -1,15 +1,29 @@ --- export interface Props { - path: string + /** Author name */ + author?: string + /** Content type for OpenGraph metadata */ contentType?: 'article' | 'website' - publishDate?: Date + /** Last modified date */ modifiedDate?: Date - author?: string + /** URL path component e.g. 'articles/my-article' */ + path: string + /** Publication date for articles */ + publishDate?: Date + /** Specifies the main, high-level category or theme of the article */ section?: string + /** Content tags */ tags?: string[] } -const { contentType = 'website', publishDate, modifiedDate, author, section, tags } = Astro.props +const { + author, + contentType = 'website', + modifiedDate, + publishDate, + section, + tags, +} = Astro.props const locale = 'en_US' --- diff --git a/src/components/Head/Social.astro b/src/components/Head/Social.astro index 221baa185..54f26989f 100644 --- a/src/components/Head/Social.astro +++ b/src/components/Head/Social.astro @@ -1,12 +1,16 @@ --- +/** + * Open Graph / Facebook / X.com metadata + */ import contactData from '@content/contact.json' import { getSocialMetadata, type SocialMetadataOptions } from '@components/Head/server' export interface Props { - title?: string + /** Meta description */ description?: string - slug?: string image?: string + slug?: string // @TODO: Should this be named "path"? Or rename "path" to "slug"? + title?: string } const { title, description, slug, image } = Astro.props @@ -21,12 +25,13 @@ if (image !== undefined) options.image = image const socialMetadata = getSocialMetadata(options) --- -{/* Open Graph / Facebook metadata */} +{/* Types include 'website', 'article', 'profile', and 'product' */} +{/* Canonical URL of the page */} diff --git a/src/components/Head/index.astro b/src/components/Head/index.astro index aadbdd1a2..0881486db 100644 --- a/src/components/Head/index.astro +++ b/src/components/Head/index.astro @@ -3,29 +3,39 @@ import Meta from '@components/Head/Meta.astro' import ThemeInit from '@components/Head/ThemeInit.astro' export interface Props { - pageTitle: string - path: string + /** Author name */ + author?: string + /** Content type for OpenGraph metadata */ + contentType?: 'article' | 'website' + /** Meta description */ description?: string image?: string + /** Last modified date */ + modifiedDate?: Date + /** Whether search engines should ignore the page, default: false */ noindex?: boolean - contentType?: 'article' | 'website' + /** Meta page title displayed in the browser tab and window title bar */ + pageTitle: string + /** URL path component e.g. 'articles/my-article' */ + path: string + /** Publication date for articles */ publishDate?: Date - modifiedDate?: Date - author?: string + /** Specifies the main, high-level category or theme of the article */ section?: string + /** Content tags */ tags?: string[] } const { - pageTitle, - path, + author, + contentType, description, image, + modifiedDate, noindex, - contentType, + pageTitle, + path, publishDate, - modifiedDate, - author, section, tags, } = Astro.props diff --git a/src/components/WebMentions/index.astro b/src/components/WebMentions/index.astro index c22b0fa3e..287c645e5 100644 --- a/src/components/WebMentions/index.astro +++ b/src/components/WebMentions/index.astro @@ -6,6 +6,7 @@ * @see https://webmention.io/ * @see https://brid.gy/ for social media integration */ +// import { Picture } from 'astro:assets' import { fetchWebmentions } from '@components/WebMentions/server' import WebMentionItem from './item.astro' diff --git a/src/components/WebMentions/item.astro b/src/components/WebMentions/item.astro index a6a101333..bd92a8426 100644 --- a/src/components/WebMentions/item.astro +++ b/src/components/WebMentions/item.astro @@ -2,6 +2,7 @@ /** * Individual WebMention item display */ +// import { Picture } from 'astro:assets' import type { Webmention } from '@components/WebMentions/@types' export interface Props { diff --git a/src/content.config.ts b/src/content.config.ts index 0267f46e4..c5184b03b 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -8,230 +8,205 @@ * The 'email' collection is included in the src/content directory, but is not handled by * Astro's collections systems. It is used by files in src/pages/api. */ -import { defineCollection, reference, z } from 'astro:content' +import { defineCollection, reference, z, type SchemaContext } from 'astro:content' import { glob, file } from 'astro/loaders' import { validTags } from '@content/_tagList' -/** Wraps a collection schema with a refinement that enforces breadcrumb title length limits */ +/** + * Wraps a collection schema with a refinement that enforces breadcrumb title length limits */ import { withBreadcrumbTitleWarning } from '@lib/helpers/breadcrumbTitleLengthRefinement' -const pattern = '**\/[^_]*.{md,mdx}' - /** - * About - */ -const aboutSchema = withBreadcrumbTitleWarning( - z.object({ - id: z.string(), - title: z.string(), - }), - 'about' -) + * NOTE: In YAML, dates written without quotes around them are interpreted as Date objects */ -const aboutCollection = defineCollection({ - loader: glob({ pattern, base: './src/content/about' }), - schema: aboutSchema, -}) +/** Only load markdown and MDX files that do not start with an underscore */ +const pattern = '**\/[^_]*.{md,mdx}' -/** - * Articles - */ -const articlesSchema = withBreadcrumbTitleWarning( +const createBaseCollectionSchema = ({ image }: SchemaContext) => z.object({ title: z.string(), description: z.string(), - // Reference a single author from the `authors` collection by `id` - author: reference('authors'), - tags: z.array(z.enum(validTags)), - image: z.object({ - src: z.string(), - alt: z.string(), - }), - // In YAML, dates written without quotes around them are interpreted as Date objects - publishDate: z.date(), - isDraft: z.boolean().default(false), + cover: image(), + coverAlt: z.string(), featured: z.boolean().default(false), - readingTime: z.string().optional(), - showToc: z.boolean().default(true), - }), - 'articles' -) - -const articlesCollection = defineCollection({ - loader: glob({ pattern, base: './src/content/articles' }), - schema: () => articlesSchema, -}) + isDraft: z.boolean().default(false), + publishDate: z.date(), + tags: z.array(z.enum(validTags)), + }) /** - * Authors + * ================================================================================= + * + * Primary Content Collections + * + * ================================================================================= */ -const authorsSchema = z.object({ - id: z.string(), - name: z.string(), - email: z.string().email(), - avatar: z.string(), - social: z.object({ - twitter: z.object({ - name: z.string(), - url: z.string().url(), - }), - github: z.object({ - name: z.string(), - url: z.string().url(), - }), - linkedin: z.object({ - name: z.string(), - url: z.string().url(), - }), - }), -}) -const authorsCollection = defineCollection({ - loader: glob({ pattern, base: './src/content/authors' }), - schema: () => authorsSchema, +/** + * Articles + */ +const articlesCollection = defineCollection({ + loader: glob({ pattern, base: './src/content/articles' }), + schema: context => + withBreadcrumbTitleWarning( + createBaseCollectionSchema(context).extend({ + author: reference('authors'), + readingTime: z.string().optional(), + showToc: z.boolean().default(true), + }), + 'articles' + ), }) /** * Case Studies */ -const caseStudiesSchema = withBreadcrumbTitleWarning( - z.object({ - title: z.string(), - description: z.string().optional(), - tags: z.array(z.enum(validTags)), - // In YAML, dates written without quotes around them are interpreted as Date objects - publishDate: z.date(), - isDraft: z.boolean().default(false), - featured: z.boolean().default(false), - // Optional fields that may exist in some case studies - image: z - .union([ - z.string(), - z.object({ - src: z.string(), - alt: z.string(), - }), - ]) - .optional(), - client: z.string().optional(), - author: reference('authors').optional(), - industry: z.string().optional(), - projectType: z.string().optional(), - duration: z.string().optional(), - }), - 'caseStudies' -) - const caseStudiesCollection = defineCollection({ loader: glob({ pattern, base: './src/content/case-studies' }), - schema: () => caseStudiesSchema, + schema: context => + withBreadcrumbTitleWarning( + createBaseCollectionSchema(context).extend({ + client: z.string().optional(), + duration: z.string().optional(), + industry: z.string().optional(), + projectType: z.string().optional(), + showToc: z.boolean().default(false), + }), + 'caseStudies' + ), }) /** - * Contact data + * Services */ -const contactDataSocialItemSchema = z.object({ - network: z.string(), - name: z.string(), - url: z.string().url(), - order: z.number(), +const servicesCollection = defineCollection({ + loader: glob({ pattern, base: './src/content/services' }), + schema: context => + withBreadcrumbTitleWarning( + createBaseCollectionSchema(context).extend({ + category: z.string().optional(), + deliverables: z.array(z.string()).optional(), + duration: z.string().optional(), + pricing: z.string().optional(), + showToc: z.boolean().default(false), + }), + 'services' + ), }) -const contactDataSchema = z.object({ - name: z.string(), - email: z.string().email(), - address: z.string(), - city: z.string(), - state: z.string().length(2), - index: z.string(), - telephoneLocal: z.string(), - telephoneMobile: z.string(), - telephoneTollFree: z.string(), - social: z.array(contactDataSocialItemSchema), +/** + * Downloads + */ +const downloadsCollection = defineCollection({ + loader: glob({ pattern, base: './src/content/downloads' }), + schema: context => + withBreadcrumbTitleWarning( + createBaseCollectionSchema(context).extend({ + author: reference('authors').optional(), + fileName: z.string(), + fileSize: z.string().optional(), + fileType: z.enum(['PDF', 'eBook', 'Whitepaper', 'Guide', 'Report', 'Template']), + pages: z.number().optional(), + readingTime: z.string().optional(), + showToc: z.boolean().default(false), + }), + 'downloads' + ), }) -const contactDataCollection = defineCollection({ - loader: file('./src/content/contact.json'), - schema: contactDataSchema, +/** + * ================================================================================= + * + * Secondary Content Collections + * + * ================================================================================= + */ + +/** + * About + */ +const aboutCollection = defineCollection({ + loader: glob({ pattern, base: './src/content/about' }), + schema: withBreadcrumbTitleWarning( + z.object({ + id: z.string(), + title: z.string(), + }), + 'about' + ), }) /** - * Downloads + * Authors */ -const downloadsSchema = withBreadcrumbTitleWarning( - z.object({ - title: z.string(), - description: z.string(), - author: reference('authors').optional(), - tags: z.array(z.enum(validTags)), - image: z.object({ - src: z.string(), - alt: z.string(), +const authorsCollection = defineCollection({ + loader: glob({ pattern, base: './src/content/authors' }), + schema: () => z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + avatar: z.string(), + social: z.object({ + twitter: z.object({ + name: z.string(), + url: z.string().url(), + }), + github: z.object({ + name: z.string(), + url: z.string().url(), + }), + linkedin: z.object({ + name: z.string(), + url: z.string().url(), + }), }), - publishDate: z.date(), - isDraft: z.boolean().default(false), - featured: z.boolean().default(false), - fileType: z.enum(['PDF', 'eBook', 'Whitepaper', 'Guide', 'Report', 'Template']), - fileSize: z.string().optional(), - pages: z.number().optional(), - readingTime: z.string().optional(), - fileName: z.string(), // Filename in public/downloads directory }), - 'downloads' -) - -const downloadsCollection = defineCollection({ - loader: glob({ pattern, base: './src/content/downloads' }), - schema: () => downloadsSchema, }) /** - * Services + * Contact data */ -const servicesSchema = withBreadcrumbTitleWarning( - z.object({ - title: z.string(), - description: z.string().optional(), - tags: z.array(z.enum(validTags)), - // In YAML, dates written without quotes around them are interpreted as Date objects - publishDate: z.date(), - isDraft: z.boolean().default(false), - category: z.string().optional(), - icon: z.string().optional(), - featured: z.boolean().default(false), - pricing: z.string().optional(), - duration: z.string().optional(), - deliverables: z.array(z.string()).optional(), +const contactDataCollection = defineCollection({ + loader: file('./src/content/contact.json'), + schema: z.object({ + name: z.string(), + email: z.string().email(), + address: z.string(), + city: z.string(), + state: z.string().length(2), + index: z.string(), + telephoneLocal: z.string(), + telephoneMobile: z.string(), + telephoneTollFree: z.string(), + social: z.array( + z.object({ + network: z.string(), + name: z.string(), + url: z.string().url(), + order: z.number(), + }), + ), }), - 'services' -) - -const servicesCollection = defineCollection({ - loader: glob({ pattern, base: './src/content/services' }), - schema: () => servicesSchema, }) /** * Testimonials. These do not have pages generated for them. */ -const testimonialSchema = z.object({ - name: z.string(), - organization: z.string().optional(), - tags: z.array(z.enum(validTags)), - avatar: z - .object({ - src: z.string(), - alt: z.string(), - }) - .optional(), - // In YAML, dates written without quotes around them are interpreted as Date objects - publishDate: z.date(), - active: z.boolean().default(false), - isDraft: z.boolean().default(false), -}) - const testimonialCollection = defineCollection({ loader: glob({ pattern, base: './src/content/testimonials' }), - schema: () => testimonialSchema, + schema: () => z.object({ + name: z.string(), + organization: z.string().optional(), + tags: z.array(z.enum(validTags)), + avatar: z + .object({ + src: z.string(), + alt: z.string(), + }) + .optional(), + publishDate: z.date(), + active: z.boolean().default(false), + isDraft: z.boolean().default(false), + }), }) /** diff --git a/public/assets/images/demo-article.jpg b/src/content/articles/demo/cover.jpg similarity index 100% rename from public/assets/images/demo-article.jpg rename to src/content/articles/demo/cover.jpg diff --git a/src/content/articles/demo/demo-image.jpg b/src/content/articles/demo/demo-image.jpg deleted file mode 100644 index c495ccb6c..000000000 Binary files a/src/content/articles/demo/demo-image.jpg and /dev/null differ diff --git a/src/content/articles/demo/index.mdx b/src/content/articles/demo/index.mdx index ac4f71afd..d1b79ccf5 100644 --- a/src/content/articles/demo/index.mdx +++ b/src/content/articles/demo/index.mdx @@ -1,15 +1,12 @@ --- title: "Demo Article - Component Showcase" description: "This is a demonstration article showcasing all available markdown components. Feel free to explore the various components and their usage patterns." -author: kevin-firko -tags: ["typescript", "cms"] -image: - src: "/assets/images/demo-article.jpg" - alt: "Demo Article Component Showcase" +cover: "./cover.jpg" +coverAlt: "Hero image for article" +author: "kevin-brown" publishDate: 2024-01-01 +tags: ["typescript", "cms"] isDraft: true -featured: false -showToc: false --- ## Avatar Component diff --git a/src/content/articles/getting-started-with-astro/cover.webp b/src/content/articles/getting-started-with-astro/cover.webp new file mode 100644 index 000000000..ea03c5cd4 Binary files /dev/null and b/src/content/articles/getting-started-with-astro/cover.webp differ diff --git a/src/content/articles/getting-started-with-astro/index.mdx b/src/content/articles/getting-started-with-astro/index.mdx index b1b65e0d0..c111e6649 100644 --- a/src/content/articles/getting-started-with-astro/index.mdx +++ b/src/content/articles/getting-started-with-astro/index.mdx @@ -1,13 +1,11 @@ --- title: "Getting Started with Astro" description: "Learn the fundamentals of building fast, content-focused websites with Astro" -author: kevin-firko -tags: ["typescript", "cms"] -image: - src: "/assets/images/placeholder-article.jpg" - alt: "Getting Started with Astro" +cover: "./cover.webp" +coverAlt: "Hero image for article" +author: "kevin-brown" publishDate: 2024-01-15 -isDraft: false +tags: ["typescript", "cms"] featured: true --- diff --git a/src/content/articles/typescript-best-practices/cover.webp b/src/content/articles/typescript-best-practices/cover.webp new file mode 100644 index 000000000..ea03c5cd4 Binary files /dev/null and b/src/content/articles/typescript-best-practices/cover.webp differ diff --git a/src/content/articles/typescript-best-practices/index.mdx b/src/content/articles/typescript-best-practices/index.mdx index 7184bb78e..88c3d06b1 100644 --- a/src/content/articles/typescript-best-practices/index.mdx +++ b/src/content/articles/typescript-best-practices/index.mdx @@ -1,14 +1,11 @@ --- title: "TypeScript Best Practices for Modern Development" description: "Essential TypeScript patterns and practices to write safer, more maintainable code" -author: kevin-firko -tags: ["typescript", "code"] -image: - src: "/assets/images/placeholder-article.jpg" - alt: "TypeScript Best Practices" +cover: "./cover.webp" +coverAlt: "Hero image for article" +author: "kevin-brown" publishDate: 2024-02-20 -isDraft: false -featured: false +tags: ["typescript", "code"] showToc: false --- diff --git a/src/content/articles/useful-vs-code-extensions/index.mdx b/src/content/articles/useful-vs-code-extensions/index.mdx index 8a4b41eb5..57f00cb02 100644 --- a/src/content/articles/useful-vs-code-extensions/index.mdx +++ b/src/content/articles/useful-vs-code-extensions/index.mdx @@ -1,11 +1,10 @@ --- title: "Useful VS Code Extensions" -publishDate: 2021-03-31 description: "Take your productivity to the next level with this carefully curated and tested list of the best VS Code extensions for projects using React, Express, Laravel, GraphQL, and more. Check out our greatest hits list and streamline your development workflow." +cover: "./cover.webp" +coverAlt: "Hero image for article" author: "kevin-brown" -image: - src: "https://docs.astro.build/assets/full-logo-light.png" - alt: "The Astro logo with the word One." +publishDate: 2021-03-31 tags: ["code", "typescript"] --- diff --git a/src/content/articles/writing-library-code/index.mdx b/src/content/articles/writing-library-code/index.mdx index 5d4899791..24094c713 100644 --- a/src/content/articles/writing-library-code/index.mdx +++ b/src/content/articles/writing-library-code/index.mdx @@ -1,11 +1,10 @@ --- title: "Designing Great TypeScript Libraries" -publishDate: 2021-04-15 description: "Writing a good library means putting yourself in other developer's shoes if you're publishing publicly and not just scratching your own itch. Here we explore tips and techniques for writing better libraries in TypeScript." +cover: "./cover.webp" +coverAlt: "Hero image for article" author: "kevin-brown" -image: - src: "https://docs.astro.build/assets/full-logo-light.png" - alt: "The Astro logo with the word One." +publishDate: 2021-04-15 tags: ["apiDesign", "code", "typescript"] --- diff --git a/src/content/authors/kevin-brown.mdx b/src/content/authors/kevin-brown.mdx index 9d8e9a31f..477f847e9 100644 --- a/src/content/authors/kevin-brown.mdx +++ b/src/content/authors/kevin-brown.mdx @@ -6,7 +6,7 @@ avatar: "/images/avatars/kevin-brown.webp" social: twitter: name: "WebstackDev" - url: "https://twitter.com/WebstackDev" + url: "https://x.com/WebstackDev" github: name: "webstackdev" url: "https://github.com/webstackdev" diff --git a/src/content/case-studies/division-15/index.mdx b/src/content/case-studies/division-15/index.mdx index 7d8f5dfbe..f1446e4f4 100644 --- a/src/content/case-studies/division-15/index.mdx +++ b/src/content/case-studies/division-15/index.mdx @@ -1,9 +1,14 @@ --- title: "Division 15 Specialty Job Board" +description: "A specialized job board platform designed for division 15 industry professionals" +cover: "./cover.webp" +coverAlt: "Hero image for case study" +client: "TechCorp" +duration: "8 months" +industry: "Retail" +projectType: "API Development" publishDate: 2021-03-31 tags: ["code", "cms"] -description: "A specialized job board platform designed for division 15 industry professionals" -author: "webstack-builders" --- # Specialty Job Board diff --git a/src/content/case-studies/ecommerce-modernization/cover.webp b/src/content/case-studies/ecommerce-modernization/cover.webp new file mode 100644 index 000000000..f52adf8ce Binary files /dev/null and b/src/content/case-studies/ecommerce-modernization/cover.webp differ diff --git a/src/content/case-studies/ecommerce-modernization/index.mdx b/src/content/case-studies/ecommerce-modernization/index.mdx index 5d7abc436..8f39ecd9c 100644 --- a/src/content/case-studies/ecommerce-modernization/index.mdx +++ b/src/content/case-studies/ecommerce-modernization/index.mdx @@ -1,15 +1,14 @@ --- title: "E-Commerce Platform Modernization" description: "How we helped a retail company modernize their e-commerce platform for better performance and scalability" -tags: ["cms", "react"] -publishDate: 2024-03-10 -isDraft: false -featured: true -author: "webstack-builders" +cover: "./cover.webp" +coverAlt: "Hero image for case study" client: "RetailCo" +duration: "6 months" industry: "Retail" projectType: "Platform Modernization" -duration: "6 months" +publishDate: 2024-03-10 +tags: ["cms", "react"] --- ## Challenge diff --git a/src/content/case-studies/english-first/index.mdx b/src/content/case-studies/english-first/index.mdx index 28b74b100..e5551702c 100644 --- a/src/content/case-studies/english-first/index.mdx +++ b/src/content/case-studies/english-first/index.mdx @@ -1,9 +1,14 @@ --- title: "Managing an LMS Implementation" +description: "A comprehensive Learning Management System implementation for English First" +cover: "./cover.webp" +coverAlt: "Hero image for case study" publishDate: 2021-03-31 tags: ["online-learning", "cms"] -description: "A comprehensive Learning Management System implementation for English First" -author: "webstack-builders" +client: "TechCorp" +duration: "8 months" +projectType: "API Development" +industry: "Retail" --- # Managing an LMS Implementation diff --git a/src/content/case-studies/enterprise-api-platform/cover.webp b/src/content/case-studies/enterprise-api-platform/cover.webp new file mode 100644 index 000000000..f52adf8ce Binary files /dev/null and b/src/content/case-studies/enterprise-api-platform/cover.webp differ diff --git a/src/content/case-studies/enterprise-api-platform/index.mdx b/src/content/case-studies/enterprise-api-platform/index.mdx index a28691789..d33ac02f1 100644 --- a/src/content/case-studies/enterprise-api-platform/index.mdx +++ b/src/content/case-studies/enterprise-api-platform/index.mdx @@ -1,15 +1,14 @@ --- title: "Enterprise API Platform Development" description: "Building a scalable API platform to power integrations across multiple business units" -tags: ["apiDesign", "graphql"] -publishDate: 2024-04-15 -isDraft: false -featured: false -author: "webstack-builders" +cover: "./cover.webp" +coverAlt: "Hero image for case study" client: "TechCorp" -industry: "Technology" -projectType: "API Development" duration: "8 months" +industry: "Retail" +projectType: "API Development" +publishDate: 2024-04-15 +tags: ["apiDesign", "graphql"] --- ## Challenge diff --git a/src/content/case-studies/labcorp/index.mdx b/src/content/case-studies/labcorp/index.mdx index 1bb127ca5..0b94d7183 100644 --- a/src/content/case-studies/labcorp/index.mdx +++ b/src/content/case-studies/labcorp/index.mdx @@ -1,9 +1,14 @@ --- title: "Healthcare Testing Kit Marketplace" +description: "A comprehensive marketplace platform for healthcare testing kits" +cover: "./cover.webp" +coverAlt: "Hero image for case study" +client: "TechCorp" +duration: "8 months" +industry: "Retail" +projectType: "API Development" publishDate: 2021-03-31 tags: ["code", "crm"] -description: "A comprehensive marketplace platform for healthcare testing kits" -author: "webstack-builders" --- # Healthcare Testing Kit Marketplace diff --git a/src/content/case-studies/us-logistics/index.mdx b/src/content/case-studies/us-logistics/index.mdx index 0003b46c2..2508e261a 100644 --- a/src/content/case-studies/us-logistics/index.mdx +++ b/src/content/case-studies/us-logistics/index.mdx @@ -1,9 +1,14 @@ --- title: "US Logistics Platform" +description: "A comprehensive logistics management platform built with modern CMS" +cover: "./cover.webp" +coverAlt: "Hero image for case study" +client: "TechCorp" +duration: "8 months" +industry: "Retail" +projectType: "API Development" publishDate: 2021-03-31 tags: ["cms", "crm"] -description: "A comprehensive logistics management platform built with modern CMS" -author: "webstack-builders" --- # [Case Study Title] diff --git a/src/assets/images/downloads/api-tool-consolidation.png b/src/content/downloads/api-tool-consolidation-whitepaper/cover.png similarity index 100% rename from src/assets/images/downloads/api-tool-consolidation.png rename to src/content/downloads/api-tool-consolidation-whitepaper/cover.png diff --git a/src/content/downloads/api-tool-consolidation-whitepaper/index.mdx b/src/content/downloads/api-tool-consolidation-whitepaper/index.mdx index 92221f1c0..f07489419 100644 --- a/src/content/downloads/api-tool-consolidation-whitepaper/index.mdx +++ b/src/content/downloads/api-tool-consolidation-whitepaper/index.mdx @@ -3,9 +3,8 @@ title: "API Tool Consolidation: The Ultimate Guide" description: "Discover how consolidating your API tools can streamline development workflows, reduce costs, and improve team collaboration. This comprehensive whitepaper explores best practices, case studies, and implementation strategies." author: "kevin-brown" tags: ["apiDesign", "devPortals", "restful"] -image: - src: "/assets/images/downloads/api-tool-consolidation.png" - alt: "API Tool Consolidation Guide cover with modern design showing connected tools and APIs" +cover: "./cover.png" +coverAlt: "Hero image for downloadable content" publishDate: 2025-01-15 isDraft: false featured: true diff --git a/src/assets/images/downloads/identity-security-dummies.png b/src/content/downloads/identity-security-for-dummies/cover.png similarity index 100% rename from src/assets/images/downloads/identity-security-dummies.png rename to src/content/downloads/identity-security-for-dummies/cover.png diff --git a/src/content/downloads/identity-security-for-dummies/index.mdx b/src/content/downloads/identity-security-for-dummies/index.mdx index c97b6a0d9..cbc870bf4 100644 --- a/src/content/downloads/identity-security-for-dummies/index.mdx +++ b/src/content/downloads/identity-security-for-dummies/index.mdx @@ -3,9 +3,8 @@ title: "Identity Security for Dummies" description: "Learn the fundamentals of identity security in today's cloud-first world. This beginner-friendly guide covers authentication, authorization, zero trust, and best practices for securing user access across your organization." author: "kevin-brown" tags: ["aws", "apiDesign", "devPortals"] -image: - src: "/assets/images/downloads/identity-security-dummies.png" - alt: "Identity Security for Dummies book cover with friendly design and security icons" +cover: "./cover.png" +coverAlt: "Hero image for downloadable content" publishDate: 2025-01-20 isDraft: false featured: false diff --git a/src/assets/images/downloads/lakehouse-analytics-guide.png b/src/content/downloads/lakehouse-analytics-guide/cover.png similarity index 100% rename from src/assets/images/downloads/lakehouse-analytics-guide.png rename to src/content/downloads/lakehouse-analytics-guide/cover.png diff --git a/src/content/downloads/lakehouse-analytics-guide/index.mdx b/src/content/downloads/lakehouse-analytics-guide/index.mdx index ef3aef538..a68dd46e5 100644 --- a/src/content/downloads/lakehouse-analytics-guide/index.mdx +++ b/src/content/downloads/lakehouse-analytics-guide/index.mdx @@ -3,9 +3,8 @@ title: "The Essential Guide to Lakehouse Analytics and AI" description: "Learn how the lakehouse architecture combines the best of data lakes and data warehouses to power modern analytics and AI workloads. Explore architecture patterns, implementation strategies, and real-world use cases." author: "kevin-brown" tags: ["aws", "databaseNormalization", "sqlOptimization"] -image: - src: "/assets/images/downloads/lakehouse-analytics-guide.png" - alt: "Lakehouse Analytics Guide cover showing unified data architecture diagram" +cover: "./cover.png" +coverAlt: "Hero image for downloadable content" publishDate: 2025-02-05 isDraft: false featured: true diff --git a/src/assets/images/downloads/observability-benefits.png b/src/content/downloads/observability-benefits-guide/cover.png similarity index 100% rename from src/assets/images/downloads/observability-benefits.png rename to src/content/downloads/observability-benefits-guide/cover.png diff --git a/src/content/downloads/observability-benefits-guide/index.mdx b/src/content/downloads/observability-benefits-guide/index.mdx index 7920d6b6e..43d5e30ec 100644 --- a/src/content/downloads/observability-benefits-guide/index.mdx +++ b/src/content/downloads/observability-benefits-guide/index.mdx @@ -3,9 +3,8 @@ title: "End-to-End Observability Benefits: The Complete Guide" description: "Discover how end-to-end observability transforms monitoring, troubleshooting, and performance optimization. Learn implementation strategies, tool selection criteria, and best practices for achieving complete system visibility." author: "kevin-brown" tags: ["aws", "deployment", "devPortals"] -image: - src: "/assets/images/downloads/observability-benefits.png" - alt: "End-to-End Observability ebook cover with distributed systems visualization" +cover: "./cover.png" +coverAlt: "Hero image for downloadable content" publishDate: 2025-01-25 isDraft: false featured: true diff --git a/src/assets/images/downloads/ransomware-recovery-kit.png b/src/content/downloads/ransomware-recovery-kit/cover.png similarity index 100% rename from src/assets/images/downloads/ransomware-recovery-kit.png rename to src/content/downloads/ransomware-recovery-kit/cover.png diff --git a/src/content/downloads/ransomware-recovery-kit/index.mdx b/src/content/downloads/ransomware-recovery-kit/index.mdx index c3574a532..bacfefd6a 100644 --- a/src/content/downloads/ransomware-recovery-kit/index.mdx +++ b/src/content/downloads/ransomware-recovery-kit/index.mdx @@ -3,9 +3,8 @@ title: "Ransomware Recovery Kit: Complete Protection Guide" description: "A comprehensive guide to protecting your organization from ransomware attacks, including prevention strategies, recovery procedures, and emergency response protocols. Essential reading for IT teams and decision makers." author: "kevin-brown" tags: ["aws", "deployment", "ci"] -image: - src: "/assets/images/downloads/ransomware-recovery-kit.png" - alt: "Ransomware Recovery Kit cover showing security shield and data protection elements" +cover: "./cover.png" +coverAlt: "Hero image for downloadable content" publishDate: 2025-02-01 isDraft: false featured: true diff --git a/src/content/services/consulting/index.mdx b/src/content/services/consulting/index.mdx index 0801424fb..b87e10ee3 100644 --- a/src/content/services/consulting/index.mdx +++ b/src/content/services/consulting/index.mdx @@ -1,5 +1,8 @@ --- title: "Consulting" +description: "Strategic consulting support for digital initiatives and product planning." +cover: "./cover.webp" +coverAlt: "Hero image for service" publishDate: 2021-03-31 tags: ["services"] featured: true diff --git a/src/content/services/overview/index.mdx b/src/content/services/overview/index.mdx index 2f660f3f4..ceb5aede4 100644 --- a/src/content/services/overview/index.mdx +++ b/src/content/services/overview/index.mdx @@ -1,5 +1,8 @@ --- title: "Services Overview" +description: "High-level overview of our consulting, training, engineering, and open source offerings." +cover: "./cover.webp" +coverAlt: "Hero image for service" publishDate: 2021-03-31 tags: ["services"] featured: true diff --git a/src/content/services/web-development/index.mdx b/src/content/services/web-development/index.mdx index a05579a5c..a2eef5cd4 100644 --- a/src/content/services/web-development/index.mdx +++ b/src/content/services/web-development/index.mdx @@ -1,5 +1,8 @@ --- title: "Web Development" +description: "Modern web development services spanning architecture, performance, and DX improvements." +cover: "./cover.webp" +coverAlt: "Hero image for service" publishDate: 2021-03-31 tags: ["services"] featured: true diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index a9b2394e0..4467f798f 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -18,35 +18,46 @@ import '@styles/index.css' import '@styles/icons.css' export interface Props { - pageTitle: string - /** The URL path component e.g. 'articles/my-article' */ - path: string + author?: string /** Enforce that child is passed to slot */ - children?: unknown + children: unknown /** Optional metadata for articles */ contentType?: 'article' | 'website' - publishDate?: Date + /** Meta description */ + description: string + // @TODO: Used in Head/Seo, factor out to social cards + image?: string + /** Last modified date */ modifiedDate?: Date - author?: string + /** Whether search engines should ignore the page, default: false */ + noindex?: boolean + /** + * Meta page title displayed in the browser tab and window title bar, + * and used in social cards and Markdown layout for the element + */ + pageTitle: string + /** The URL path component e.g. 'articles/my-article' */ + path: string + /** Publication date for articles */ + publishDate?: Date + /** Specifies the main, high-level category or theme of the article */ section?: string + /** Content tags */ tags?: string[] - description?: string - image?: string - noindex?: boolean } const { + author, + contentType, + description, + image, + modifiedDate, + noindex, pageTitle, path, - contentType, publishDate, - modifiedDate, - author, section, tags, - description, - image, - noindex, } = Astro.props --- @@ -58,7 +69,7 @@ const { <HeadContent pageTitle={pageTitle} path={path} - {...description && { description }} + description={ description } {...image && { image }} {...noindex && { noindex }} {...contentType && { contentType }} diff --git a/src/layouts/MarkdownLayout.astro b/src/layouts/MarkdownLayout.astro index dc24f7fb3..437cf5f7e 100644 --- a/src/layouts/MarkdownLayout.astro +++ b/src/layouts/MarkdownLayout.astro @@ -1,6 +1,6 @@ --- import { type CollectionEntry, render } from 'astro:content' -import { Image } from 'astro:assets' +import { Image, Picture } from 'astro:assets' import type { MarkdownHeading } from 'astro' /** @@ -27,58 +27,60 @@ import Shares from '@components/Social/Shares/index.astro' import Testimonials from '@components/Testimonials/index.astro' /** Export components for use in MDX */ -const Components = { Avatar, Callout, Carousel, Contact, Embed, Featured, Highlighter, Icon, Image, MastodonModal, Newsletter, Shares, Testimonials } +const Components = { Avatar, Callout, Carousel, Contact, Embed, Featured, Highlighter, Icon, Image, MastodonModal, Newsletter, Picture, Shares, Testimonials } export interface Props { - /** Page title shown in <title> and H1 */ - pageTitle: string - /** URL path component e.g. 'articles/my-article' */ - path: string - /** Optional page subtitle or excerpt */ - subtitle?: string - /** Content type for OpenGraph metadata */ - contentType?: 'article' | 'website' + /** Author name */ + author?: string /** Raw Markdown content from "body" property on content collection */ collectionItem: | CollectionEntry<'articles'> | CollectionEntry<'services'> | CollectionEntry<'caseStudies'> - | CollectionEntry<'downloads'> - /** Publication date for articles */ - publishDate?: Date + | CollectionEntry<'downloads'>; + /** Custom CSS class for content wrapper */ + contentClass?: string + /** Content type for OpenGraph metadata */ + contentType?: 'article' | 'website' + /** Meta description */ + description: string + /** Page title shown in <title> and H1 */ + pageTitle: string + /** URL path component e.g. 'articles/my-article' */ + path: string /** Last modified date */ modifiedDate?: Date - /** Author name */ - author?: string - /** Article tags */ - tags?: string[] - /** Meta description */ - description?: string + /** Publication date for articles */ + publishDate?: Date /** Social share image */ - image?: string + image: string /** Estimated reading time */ readingTime?: string + /** Specifies the main, high-level category or theme of the article */ + section: string /** Whether to show table of contents */ showToc?: boolean - /** Custom CSS class for content wrapper */ - contentClass?: string + /** Optional page subtitle or excerpt */ + subtitle?: string + /** Content tags */ + tags?: string[] } const { - pageTitle, - path, - subtitle, - contentType = 'article', - collectionItem, - publishDate, - modifiedDate, author, - tags, + collectionItem, + contentClass = '', + contentType = 'article', description, image, + modifiedDate, + pageTitle, + path, + publishDate, readingTime, showToc, - contentClass = '', + subtitle, + tags, } = Astro.props // Schema.org itemtype based on content type @@ -87,25 +89,22 @@ const schemaType = ? 'http://schema.org/Article' : 'http://schema.org/WebPage' -// Build BaseLayout props object with only defined values -const baseLayoutProps = { - pageTitle, - path, - contentType, - ...(publishDate && { publishDate }), - ...(modifiedDate && { modifiedDate }), - ...(author && { author }), - ...(tags && { tags }), - ...(description && { description }), - ...(image && { image }), -} - const { Content, headings = [] } = await render(collectionItem) const tocHeadings: MarkdownHeading[] = headings.filter(heading => heading.depth > 1) const shouldRenderToc = showToc !== false && tocHeadings.length > 0 --- -<BaseLayout {...baseLayoutProps}> +<BaseLayout + pageTitle={pageTitle} + path={path} + description={description} + contentType={contentType} + {...publishDate && { publishDate }} + {...modifiedDate && { modifiedDate }} + {...author && { author }} + {...tags && { tags }} + {...image && { image }} +> <article class={`max-w-[75ch] mx-auto py-8 px-4 ${contentClass}`} itemscope diff --git a/src/layouts/PageLayout.astro b/src/layouts/PageLayout.astro index e529360f3..33d6e2436 100644 --- a/src/layouts/PageLayout.astro +++ b/src/layouts/PageLayout.astro @@ -1,20 +1,13 @@ --- /** - * Page layout used for item view pages for dynamic collections like articles, - * case studies, and services, and dynamic token newsletter confirm pages. + * Page layout used for item view pages for primary collections like articles, + * case studies, and services, and also dynamic token newsletter confirm pages. */ import BaseLayout from '@layouts/BaseLayout.astro' export interface Props { - pageTitle: string - /** The URL path component e.g. 'articles/my-article' */ - path: string - /** Optional page subtitle */ - subtitle?: string - /** Optional page description for meta tags */ - description?: string - /** Schema.org itemtype for structured data */ - schemaType?: string + /** Page description for meta tags */ + description: string /** Article metadata for blog posts */ meta?: { date?: Date @@ -23,20 +16,41 @@ export interface Props { readingTime?: string draft?: boolean } + /** Meta page title displayed in the browser tab and window title bar */ + pageTitle: string + /** The URL path component e.g. 'articles/my-article' */ + path: string + /** Specifies the main, high-level category or theme of the article */ + section: string + /** Optional page subtitle */ + subtitle?: string + /** Schema.org itemtype for structured data */ + schemaType?: string } const { + description, + meta, pageTitle, path, - subtitle, - description, schemaType = 'http://schema.org/WebPage', - meta, + section, + subtitle, } = Astro.props --- -<BaseLayout pageTitle={pageTitle} path={path} {...description && { description }}> - <article class="page-content" itemscope itemtype={schemaType} aria-labelledby="page-title"> +<BaseLayout + pageTitle={pageTitle} + path={path} + description={description} + section={section} +> + <article + class="page-content" + itemscope + itemtype={schemaType} + aria-labelledby="page-title" + > <header class="page-content__header"> <h1 id="page-title" class="page-content__title" itemprop="headline"> {pageTitle} diff --git a/src/pages/404.astro b/src/pages/404.astro index 02c503f9b..f2ed4fe71 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -2,11 +2,17 @@ import BaseLayout from '@layouts/BaseLayout.astro' import NotFoundHero from '@components/Errors/NotFoundHero/index.astro' +const pageDescription = '404 Not Found page' const pageTitle = 'Oops! Not Found' const path = '/404/' --- -<BaseLayout pageTitle={pageTitle} path={path} noindex={true}> +<BaseLayout + description={pageDescription} + noindex={true} + pageTitle={pageTitle} + path={path} +> <div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8"> <div class="max-w-lg w-full"> <NotFoundHero /> diff --git a/src/pages/articles/[...slug].astro b/src/pages/articles/[...slug].astro index 433aa9842..35bc27932 100644 --- a/src/pages/articles/[...slug].astro +++ b/src/pages/articles/[...slug].astro @@ -1,5 +1,6 @@ --- -import { type CollectionEntry, getCollection } from 'astro:content' +import { type CollectionEntry, getCollection, getEntry } from 'astro:content' +import { Picture } from 'astro:assets' import { isDev, isProd } from '@lib/config/environmentServer' import MarkdownLayout from '@layouts/MarkdownLayout.astro' import Shares from '@components/Social/Shares/index.astro' @@ -23,34 +24,43 @@ export async function getStaticPaths() { const { article } = Astro.props +const author = await getEntry(article.data.author) const path = `/articles/${article.id}` -const pageTitle = article.data.title -const cover = article.data.image -const authorId = - typeof article.data.author === 'string' ? article.data.author : article.data.author.id +const section = `Articles` --- <MarkdownLayout - pageTitle={pageTitle} - path={path} - contentType="article" + author={author.data.name} collectionItem={article} + contentType="article" + image={article.data.cover.src} + description={article.data.description} + pageTitle={article.data.title} + path={path} publishDate={article.data.publishDate} - author={authorId} + section={section} + showToc={article.data.showToc ?? true} tags={article.data.tags} - description={article.data.description} - {...(cover?.src && { image: cover.src })} {...(article.data.readingTime && { readingTime: article.data.readingTime })} - showToc={article.data.showToc ?? true} > {/** SLOT: hero-image */} { - cover && ( + article.data.cover && ( <div - class="aspect-video max-w-3xl mx-auto rounded-2xl overflow-hidden shadow-lg mb-8" + class="relative aspect-video max-w-3xl mx-auto rounded-2xl overflow-hidden shadow-lg mb-8" slot="hero-image" > - <img src={cover.src} alt={cover.alt} loading="eager" class="w-full h-full object-cover" /> + <Picture + src={article.data.cover} + alt={article.data.coverAlt} + widths={[320, 640, 960, 1280]} + sizes="(min-width: 1024px) 66vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full" + /> </div> ) } @@ -68,8 +78,8 @@ const authorId = </div> <Shares url={`https://webstackbuilders.com${path}`} - title={pageTitle} - description={`Read "${pageTitle}" on Webstack Builders`} + title={article.data.title} + description={`Read "${article.data.title}" on Webstack Builders`} class="shrink-0" /> </div> diff --git a/src/pages/articles/index.astro b/src/pages/articles/index.astro index cc91bfee6..7057f10b9 100644 --- a/src/pages/articles/index.astro +++ b/src/pages/articles/index.astro @@ -1,24 +1,29 @@ --- import { getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import { isDev, isProd } from '@lib/config/environmentServer' import PageLayout from '@layouts/PageLayout.astro' -const pageTitle = 'Articles' -const path = '/articles/' - const allArticles = await getCollection('articles', ({ data }) => { return isDev() || (isProd() && data.isDraft !== true) }) -// Sort by publish date (newest first) +/** Sort by publish date (newest first) */ const sortedArticles = allArticles.sort( (a, b) => new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime() ) + +const pageDescription = 'Webstack Builders Articles List' +const pageTitle = 'All Articles' +const path = '/articles/' +const section = 'Articles' --- <PageLayout + description={pageDescription} pageTitle={pageTitle} path={path} + section={section} subtitle="Insights, tutorials, and thoughts on web development" > <section class="space-y-12"> @@ -26,13 +31,18 @@ const sortedArticles = allArticles.sort( sortedArticles.map(article => ( <article class="bg-bg rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> <a href={`/articles/${article.id}`} class="block"> - {article.data.image && ( - <div class="aspect-video overflow-hidden"> - <img - src={article.data.image.src} - alt={article.data.image.alt} - loading="lazy" - class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" + {article.data.cover && ( + <div class="relative aspect-video overflow-hidden"> + <Picture + src={article.data.cover} + alt={article.data.coverAlt} + widths={[320, 640, 960, 1280]} + sizes="(min-width: 1280px) 25vw, (min-width: 768px) 50vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full" /> </div> )} diff --git a/src/pages/case-studies/[slug].astro b/src/pages/case-studies/[slug].astro index 2f9bd93a9..a24b20f76 100644 --- a/src/pages/case-studies/[slug].astro +++ b/src/pages/case-studies/[slug].astro @@ -1,5 +1,6 @@ --- import { getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import MarkdownLayout from '@layouts/MarkdownLayout.astro' import Carousel from '@components/Carousel/index.astro' import WebMentions from '@components/WebMentions/index.astro' @@ -14,34 +15,42 @@ export async function getStaticPaths() { } const { caseStudy } = Astro.props + const path = `/case-studies/${caseStudy.id}` -const pageTitle = caseStudy.data.title -const cover = caseStudy.data.image - ? typeof caseStudy.data.image === 'string' - ? { src: caseStudy.data.image, alt: caseStudy.data.title } - : caseStudy.data.image - : undefined +const section = 'Case Studies' --- <MarkdownLayout - pageTitle={pageTitle} - path={path} - contentType="article" collectionItem={caseStudy} + contentType="article" + description={caseStudy.data.description} + image={caseStudy.data.cover.src} + pageTitle={caseStudy.data.title} + path={path} publishDate={caseStudy.data.publishDate} - tags={caseStudy.data.tags} - {...(caseStudy.data.description && { description: caseStudy.data.description })} - {...(cover?.src && { image: cover.src })} + section={section} showToc={false} + tags={caseStudy.data.tags} > {/** SLOT: hero-image */} { - cover && ( + caseStudy.data.cover && ( <div - class="aspect-video max-w-3xl mx-auto rounded-2xl overflow-hidden shadow-lg mb-8" + class="relative aspect-video max-w-3xl mx-auto rounded-2xl overflow-hidden shadow-lg mb-8" slot="hero-image" > - <img src={cover.src} alt={cover.alt} loading="eager" class="w-full h-full object-cover" /> + <Picture + src={caseStudy.data.cover} + alt={caseStudy.data.coverAlt} + widths={[640, 960, 1280, 1920]} + sizes="(min-width: 1280px) 80vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover" + loading="eager" + /> </div> ) } diff --git a/src/pages/case-studies/index.astro b/src/pages/case-studies/index.astro index 058e65bf6..f66623ebb 100644 --- a/src/pages/case-studies/index.astro +++ b/src/pages/case-studies/index.astro @@ -1,84 +1,91 @@ --- import { getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import PageLayout from '@layouts/PageLayout.astro' const allCaseStudies = await getCollection('caseStudies') -const path = '/case-studies/' -// Sort by publish date (newest first) +/** Sort by publish date (newest first) */ const sortedCaseStudies = allCaseStudies.sort( (a, b) => new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime() ) + +const pageDescription = 'Webstack Builders Case Studies List' +const pageTitle = 'All Case Studies' +const path = '/case-studies/' +const section = 'Case Studies' --- <PageLayout - pageTitle="Case Studies" + description={pageDescription} + pageTitle={pageTitle} path={path} + section={section} subtitle="Explore our successful projects and client outcomes" > <section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12"> { - sortedCaseStudies.map(caseStudy => ( - <article class="bg-bg rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> - <a href={`/case-studies/${caseStudy.id}`} class="block h-full"> - {caseStudy.data.image && ( - <div class="aspect-video overflow-hidden"> - <img - src={ - typeof caseStudy.data.image === 'string' - ? caseStudy.data.image - : caseStudy.data.image.src - } - alt={ - typeof caseStudy.data.image === 'string' - ? caseStudy.data.title - : caseStudy.data.image.alt - } - loading="lazy" - class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" - /> - </div> - )} - - <div class="p-6 space-y-4"> - <h2 class="text-xl font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300"> - {caseStudy.data.title} - </h2> - - {caseStudy.data.description && ( - <p class="text-text-muted text-sm leading-relaxed line-clamp-3"> - {caseStudy.data.description} - </p> + sortedCaseStudies.map(caseStudy => { + return ( + <article class="bg-bg rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> + <a href={`/case-studies/${caseStudy.id}`} class="block h-full"> + {caseStudy.data.cover && ( + <div class="relative aspect-video overflow-hidden"> + <Picture + src={caseStudy.data.cover} + alt={caseStudy.data.coverAlt} + widths={[320, 640, 960, 1280]} + sizes="(min-width: 1280px) 25vw, (min-width: 768px) 50vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 block h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" + loading="lazy" + /> + </div> )} - <div class="space-y-3"> - {caseStudy.data.client && ( - <span class="inline-block text-sm font-medium text-primary bg-primary-bg px-3 py-1 rounded-full"> - Client: {caseStudy.data.client} - </span> - )} + <div class="p-6 space-y-4"> + <h2 class="text-xl font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300"> + {caseStudy.data.title} + </h2> - <time class="block text-sm text-text-muted"> - {caseStudy.data.publishDate.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - })} - </time> - </div> + {caseStudy.data.description && ( + <p class="text-text-muted text-sm leading-relaxed line-clamp-3"> + {caseStudy.data.description} + </p> + )} - {caseStudy.data.tags && ( - <div class="flex flex-wrap gap-2 pt-2"> - {caseStudy.data.tags.slice(0, 3).map((tag: string) => ( - <span class="text-xs font-medium text-text-muted bg-bg-offset px-2 py-1 rounded-full"> - {tag} + <div class="space-y-3"> + {caseStudy.data.client && ( + <span class="inline-block text-sm font-medium text-primary bg-primary-bg px-3 py-1 rounded-full"> + Client: {caseStudy.data.client} </span> - ))} + )} + + <time class="block text-sm text-text-muted"> + {caseStudy.data.publishDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + })} + </time> </div> - )} - </div> - </a> - </article> - )) + + {caseStudy.data.tags && ( + <div class="flex flex-wrap gap-2 pt-2"> + {caseStudy.data.tags.slice(0, 3).map((tag: string) => ( + <span class="text-xs font-medium text-text-muted bg-bg-offset px-2 py-1 rounded-full"> + {tag} + </span> + ))} + </div> + )} + </div> + </a> + </article> + ) + }) } </section> </PageLayout> diff --git a/src/pages/consent/index.astro b/src/pages/consent/index.astro index c16b4284f..af6f8e1ce 100644 --- a/src/pages/consent/index.astro +++ b/src/pages/consent/index.astro @@ -4,9 +4,10 @@ import ConsentPreferences from '@components/Consent/Preferences/index.astro' const pageTitle = 'Consent Policy' const path = '/consent' +const description = 'Review how Webstack Builders uses cookies and update your consent preferences at any time.' --- -<BaseLayout pageTitle={pageTitle} path={path}> +<BaseLayout pageTitle={pageTitle} path={path} description={description}> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <header class="text-center mb-16"> <h1 class="text-4xl md:text-5xl font-bold text-text mb-6"> diff --git a/src/pages/contact/index.astro b/src/pages/contact/index.astro index 02e5530ad..db4260001 100644 --- a/src/pages/contact/index.astro +++ b/src/pages/contact/index.astro @@ -4,9 +4,10 @@ import ContactForm from '@components/Forms/Contact/index.astro' const pageTitle = 'Contact Us' const path = '/contact' +const description = 'Connect with the Webstack Builders team to discuss your project, request a proposal, or ask questions.' --- -<BaseLayout pageTitle={pageTitle} path={path}> +<BaseLayout pageTitle={pageTitle} path={path} description={description}> <div class="min-h-screen"> <!-- Contact Hero Section --> <section diff --git a/src/pages/downloads/[...slug].astro b/src/pages/downloads/[...slug].astro index 76ef24203..ada77b19b 100644 --- a/src/pages/downloads/[...slug].astro +++ b/src/pages/downloads/[...slug].astro @@ -1,5 +1,6 @@ --- import { type CollectionEntry, getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import { isDev, isProd } from '@lib/config/environmentServer' import MarkdownLayout from '@layouts/MarkdownLayout.astro' import DownloadForm from '@components/Forms/Download/index.astro' @@ -20,35 +21,42 @@ export async function getStaticPaths() { } const { download } = Astro.props + const path = `/downloads/${download.id}` -const pageTitle = download.data.title -const cover = download.data.image -const authorId = - typeof download.data.author === 'string' - ? download.data.author - : download.data.author?.id || 'webstack-builders' +const section = 'Downloads' --- <MarkdownLayout - pageTitle={pageTitle} - path={path} - contentType="article" collectionItem={download} + contentType="article" + description={download.data.description} + image={download.data.cover.src} + pageTitle={download.data.title} + path={path} publishDate={download.data.publishDate} - author={authorId} + section={section} + showToc={false} tags={download.data.tags} - description={download.data.description} - {...(cover?.src && { image: cover.src })} {...(download.data.readingTime && { readingTime: download.data.readingTime })} - showToc={false} > { - cover && ( + download.data.cover && ( <div - class="aspect-video max-w-3xl mx-auto rounded-2xl overflow-hidden shadow-lg mb-8" + class="relative aspect-video max-w-3xl mx-auto rounded-2xl overflow-hidden shadow-lg mb-8" slot="hero-image" > - <img src={cover.src} alt={cover.alt} loading="eager" class="w-full h-full object-cover" /> + <Picture + src={download.data.cover} + alt={download.data.coverAlt} + widths={[640, 960, 1280, 1920]} + sizes="(min-width: 1280px) 80vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover" + loading="eager" + /> </div> ) } @@ -138,6 +146,10 @@ const authorId = <!-- Download Form --> <div class="border-t border-border pt-12 mt-12" slot="after-content"> - <DownloadForm title={pageTitle} fileName={download.data.fileName} fileType={download.data.fileType} /> + <DownloadForm + title={download.data.title} + fileName={download.data.fileName} + fileType={download.data.fileType} + /> </div> </MarkdownLayout> diff --git a/src/pages/index.astro b/src/pages/index.astro index 361438da7..656fc1481 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -6,11 +6,16 @@ import Testimonials from '@components/Testimonials/index.astro' import Contact from '@components/CallToAction/Contact/index.astro' import Newsletter from '@components/CallToAction/Newsletter/index.astro' -const pageTitle = 'Web Development & Digital Solutions - Webstack Builders' +const pageDescription = 'Webstack Builders, Inc. Home Page' +const pageTitle = 'Web Development & Digital Solutions - Webstack Builders, Inc.' const path = '/' --- -<BaseLayout pageTitle={pageTitle} path={path}> +<BaseLayout + description={pageDescription} + pageTitle={pageTitle} + path={path} +> <!-- Hero Section --> <Hero /> diff --git a/src/pages/newsletter/confirm/[token].astro b/src/pages/newsletter/confirm/[token].astro index 0175273cd..e8b08a9c6 100644 --- a/src/pages/newsletter/confirm/[token].astro +++ b/src/pages/newsletter/confirm/[token].astro @@ -11,12 +11,19 @@ import PageLayout from '@layouts/PageLayout.astro' // This is a server-rendered dynamic route export const prerender = false -// Initialize page title for server-side render +const description = 'Double opt-in confirmation for your Webstack Builders newsletter subscription.' const pageTitle = 'Confirming Your Subscription...' +const section = 'Newsletter Subscription Confirmation' const subtitle = 'Please wait while we confirm your subscription' --- -<PageLayout pageTitle={pageTitle} path="newsletter/confirm" subtitle={subtitle}> +<PageLayout + pageTitle={pageTitle} + path="newsletter/confirm" + section={section} + subtitle={subtitle} + description={description} +> <div class="container mx-auto px-4 py-16 max-w-2xl"> <div class="bg-base border border-offset rounded-lg p-8 shadow-sm"> diff --git a/src/pages/offline/index.astro b/src/pages/offline/index.astro index 061963815..8dd1b2d10 100644 --- a/src/pages/offline/index.astro +++ b/src/pages/offline/index.astro @@ -2,9 +2,11 @@ import BaseLayout from '@layouts/BaseLayout.astro' import NetworkStatus from '@components/Toasts/NetworkStatus/index.astro' import NotFoundHero from '@components/Errors/NotFoundHero/index.astro' + +const description = 'Offline fallback for Webstack Builders with guidance on what to do until you reconnect.' --- -<BaseLayout pageTitle="Offline - Webstack Builders" path="offline"> +<BaseLayout pageTitle="Offline - Webstack Builders" path="offline" description={description}> <!-- Network status monitoring --> <NetworkStatus /> diff --git a/src/pages/privacy/index.astro b/src/pages/privacy/index.astro index cd593c0c8..faf7df737 100644 --- a/src/pages/privacy/index.astro +++ b/src/pages/privacy/index.astro @@ -4,9 +4,10 @@ import contactData from '@content/contact.json' const pageTitle = 'Privacy Policy' const path = '/privacy' +const description = 'Learn how Webstack Builders collects, uses, and protects your personal information.' --- -<BaseLayout pageTitle={pageTitle} path={path}> +<BaseLayout pageTitle={pageTitle} path={path} description={description}> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <header class="text-center mb-16"> <h1 class="text-4xl md:text-5xl font-bold text-text mb-6"> diff --git a/src/pages/privacy/my-data.astro b/src/pages/privacy/my-data.astro index 9f716b8aa..f7fbcc537 100644 --- a/src/pages/privacy/my-data.astro +++ b/src/pages/privacy/my-data.astro @@ -3,6 +3,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' const pageTitle = 'My Data - Privacy Rights' const path = 'privacy/my-data' +const description = 'Exercise your GDPR rights by requesting a copy of your data or asking us to delete it.' // Get status from query params const status = Astro.url.searchParams.get('status') @@ -17,7 +18,7 @@ const statusMessages: Record<string, { type: 'success' | 'error' | 'info', messa } --- -<BaseLayout pageTitle={pageTitle} path={path}> +<BaseLayout pageTitle={pageTitle} path={path} description={description}> <div class="privacy-page"> <h1>My Data & Privacy Rights</h1> diff --git a/src/pages/services/[slug].astro b/src/pages/services/[slug].astro index 177280aed..dc6fcb82e 100644 --- a/src/pages/services/[slug].astro +++ b/src/pages/services/[slug].astro @@ -1,5 +1,6 @@ --- import { getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import MarkdownLayout from '@layouts/MarkdownLayout.astro' import Carousel from '@components/Carousel/index.astro' import WebMentions from '@components/WebMentions/index.astro' @@ -14,30 +15,43 @@ export async function getStaticPaths() { } const { service } = Astro.props + const path = `/services/${service.id}` const pageTitle = service.data.title -const icon = service.data.icon ? { src: service.data.icon, alt: service.data.title } : undefined +const section = 'Downloads' --- <MarkdownLayout + collectionItem={service} + contentType="article" + description={service.data.description} + image={service.data.cover.src} pageTitle={pageTitle} path={path} - contentType="article" - collectionItem={service} publishDate={service.data.publishDate} - tags={service.data.tags} - {...(service.data.description && { description: service.data.description })} - {...(icon?.src && { image: icon.src })} + section={section} showToc={false} + tags={service.data.tags} > {/** SLOT: hero-image */} { - icon && ( + service.data.cover && ( <div - class="w-20 h-20 mx-auto mb-8 bg-primary-offset rounded-xl flex items-center justify-center" + class="relative w-20 h-20 mx-auto mb-8 bg-primary-offset rounded-xl overflow-hidden" slot="hero-image" > - <img src={icon.src} alt={icon.alt} loading="eager" class="w-10 h-10" /> + <Picture + src={service.data.cover} + alt={service.data.coverAlt} + widths={[80, 120, 160]} + sizes="80px" + formats={['avif', 'webp', 'png']} + layout="fixed" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover" + loading="eager" + /> </div> ) } diff --git a/src/pages/services/index.astro b/src/pages/services/index.astro index bff676701..7a93e4bda 100644 --- a/src/pages/services/index.astro +++ b/src/pages/services/index.astro @@ -1,24 +1,28 @@ --- import { getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import PageLayout from '@layouts/PageLayout.astro' -const path = '/services/' -const description = 'Professional web development and consulting services to help you build better software faster.' - -// Get all services from content collection +/** Get all services from content collection */ const allServices = await getCollection('services') -// Filter and sort services +/** Filter and sort services */ const services = allServices.sort( (a, b) => new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime() ) + +const pageDescription = 'Webstack Builders Services List' +const pageTitle = 'All Services' +const path = '/services/' +const section = 'Services' --- <PageLayout - pageTitle="Services" + description={pageDescription} + pageTitle={pageTitle} path={path} + section={section} subtitle="Professional web development and consulting services" - description={description} > { services.length > 0 && ( @@ -29,23 +33,30 @@ const services = allServices.sort( </h2> </header> <ul class="space-y-12 md:space-y-20 lg:space-y-20"> - {services.map(service => ( - <li class="service-item"> - <a - href={`/services/${service.id}`} - class="block group hover:bg-bg-offset rounded-lg p-6 transition-colors duration-200 border border-transparent hover:border-border" - > - <div class="flex items-start space-x-6"> - {service.data.icon && ( - <div class="shrink-0 w-16 h-16 bg-primary-bg rounded-lg flex items-center justify-center group-hover:bg-primary-bg-hover transition-colors"> - <img - src={service.data.icon} - alt="" - loading="lazy" - class="w-8 h-8 text-primary" - /> - </div> - )} + {services.map(service => { + return ( + <li class="service-item"> + <a + href={`/services/${service.id}`} + class="block group hover:bg-bg-offset rounded-lg p-6 transition-colors duration-200 border border-transparent hover:border-border" + > + <div class="flex items-start space-x-6"> + {service.data.cover && ( + <div class="relative shrink-0 w-16 h-16 bg-primary-bg rounded-lg overflow-hidden group-hover:bg-primary-bg-hover transition-colors"> + <Picture + src={service.data.cover} + alt={service.data.coverAlt} + widths={[64, 96, 128, 192]} + sizes="64px" + formats={['avif', 'webp', 'png']} + layout="fixed" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover" + loading="lazy" + /> + </div> + )} <div class="flex-1 min-w-0"> <h3 class="text-xl md:text-2xl font-semibold text-text mb-3 group-hover:text-primary transition-colors"> {service.data.title} @@ -74,7 +85,8 @@ const services = allServices.sort( </div> </a> </li> - ))} + ) + })} </ul> </section> ) diff --git a/src/pages/tags/[tag].astro b/src/pages/tags/[tag].astro index c1b24bf93..6342844f9 100644 --- a/src/pages/tags/[tag].astro +++ b/src/pages/tags/[tag].astro @@ -1,5 +1,6 @@ --- import { type CollectionEntry, getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import { isDev, isProd } from '@lib/config/environmentServer' import BaseLayout from '@layouts/BaseLayout.astro' import { exceptions } from '@content/_tagList' @@ -76,24 +77,26 @@ const getTagDisplayName = (tag: string) => { return exceptions[tag as keyof typeof exceptions] || tag } -const displayName = getTagDisplayName(tag) -const path = `/tags/${tag}/` - -// Type guard to check if image is an object with src/alt properties -type ImageObject = { src: string; alt: string } -const isImageObject = (img: unknown): img is ImageObject => { - return typeof img === 'object' && img !== null && 'src' in img && 'alt' in img -} - const typeMetadata = { article: { label: 'Article', badgeClass: 'bg-primary', hrefBase: 'articles' }, 'case-study': { label: 'Case Study', badgeClass: 'bg-success', hrefBase: 'case-studies' }, service: { label: 'Service', badgeClass: 'bg-accent', hrefBase: 'services' }, download: { label: 'Download', badgeClass: 'bg-secondary', hrefBase: 'downloads' }, } as const + +const displayName = getTagDisplayName(tag) +const path = `/tags/${tag}/` +const pageDescription= `List of content tagged with ${displayName}` +const pageTitle = `${displayName} - Tag` +const section = 'Tags' --- -<BaseLayout pageTitle={`${displayName} - Tag`} path={path}> +<BaseLayout + pageTitle={pageTitle} + path={path} + description={pageDescription} + section={section} +> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <header class="text-center mb-12"> <h1 class="text-4xl md:text-5xl font-bold text-text mb-4"> @@ -115,85 +118,81 @@ const typeMetadata = { <section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12 mb-12"> {paginatedContent.map(item => { const typeMeta = typeMetadata[item.type] + const href = `/${typeMeta.hrefBase}/${item.id}` + return ( <article class="bg-bg rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> - <a href={`/${typeMeta.hrefBase}/${item.id}`} class="block h-full"> - {'image' in item.data && item.data.image && ( - <div class="aspect-video overflow-hidden"> - <img - src={ - typeof item.data.image === 'string' - ? item.data.image - : isImageObject(item.data.image) - ? item.data.image.src - : String(item.data.image) - } - alt={ - typeof item.data.image === 'string' - ? item.data.title - : isImageObject(item.data.image) - ? item.data.image.alt - : item.data.title - } - loading="lazy" - class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" - /> - </div> - )} - - <div class="p-6 space-y-4"> - <div class="flex items-center gap-3 text-sm"> - <span - class={`px-3 py-1 rounded-full text-white font-medium ${typeMeta.badgeClass}`} - > - {typeMeta.label} - </span> - <time class="text-text-muted"> - {item.data.publishDate.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - </time> - </div> - - <h2 class="text-xl font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300"> - {item.data.title} - </h2> - - {item.data.description && ( - <p class="text-text-muted text-sm leading-relaxed line-clamp-3"> - {item.data.description} - </p> + <a href={href} class="block h-full"> + {item.data.cover && ( + <div class="relative aspect-video overflow-hidden"> + <Picture + src={item.data.cover} + alt={item.data.coverAlt} + widths={[320, 640, 960, 1280]} + sizes="(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" + loading="lazy" + /> + </div> )} - <div class="space-y-3 pt-2"> - {/* Client or Author info */} - {'client' in item.data && item.data.client && ( - <span class="inline-block text-sm font-medium text-primary bg-primary-bg px-3 py-1 rounded-full"> - Client: {item.data.client} + <div class="p-6 space-y-4"> + <div class="flex items-center gap-3 text-sm"> + <span + class={`px-3 py-1 rounded-full text-white font-medium ${typeMeta.badgeClass}`} + > + {typeMeta.label} </span> - )} + <time class="text-text-muted"> + {item.data.publishDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + </time> + </div> - {/* Additional tags */} - {item.data.tags && item.data.tags.length > 1 && ( - <div class="flex flex-wrap gap-2"> - {item.data.tags - .filter((t: string) => t !== tag) - .slice(0, 3) - .map((relatedTag: string) => ( - <a - href={`/tags/${relatedTag}/`} - class="text-xs font-medium text-text-muted bg-bg-offset hover:bg-border px-2 py-1 rounded-full transition-colors duration-200" - > - {getTagDisplayName(relatedTag)} - </a> - ))} - </div> + <h2 class="text-xl font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300"> + {item.data.title} + </h2> + + {item.data.description && ( + <p class="text-text-muted text-sm leading-relaxed line-clamp-3"> + {item.data.description} + </p> )} + + <div class="space-y-3 pt-2"> + {/* Client or Author info */} + {'client' in item.data && item.data.client && ( + <span class="inline-block text-sm font-medium text-primary bg-primary-bg px-3 py-1 rounded-full"> + Client: {item.data.client} + </span> + )} + + {/* Additional tags */} + {item.data.tags && item.data.tags.length > 1 && ( + <div class="flex flex-wrap gap-2"> + {item.data.tags + .filter((t: string) => t !== tag) + .slice(0, 3) + .map((relatedTag: string) => ( + <a + href={`/tags/${relatedTag}/`} + class="text-xs font-medium text-text-muted bg-bg-offset hover:bg-border px-2 py-1 rounded-full transition-colors duration-200" + > + {getTagDisplayName(relatedTag)} + </a> + ))} + </div> + )} + </div> </div> - </div> - </a> + </a> </article> ) })} diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro index 9ed118168..245b4957c 100644 --- a/src/pages/tags/index.astro +++ b/src/pages/tags/index.astro @@ -1,5 +1,6 @@ --- import { getCollection } from 'astro:content' +import { Picture } from 'astro:assets' import { isDev, isProd } from '@lib/config/environmentServer' import BaseLayout from '@layouts/BaseLayout.astro' import { exceptions } from '@content/_tagList' @@ -39,17 +40,18 @@ const getContentPreviewForTag = (tag: string, limit: number = 4) => { return allContent.filter(item => item.data.tags && item.data.tags.includes(tag)).slice(0, limit) } -const pageTitle = 'Tags' +const pageDescription = 'Webstack Builders Services List' +const pageTitle = 'All Tags' const path = '/tags/' - -// Type guard to check if image is an object with src/alt properties -type ImageObject = { src: string; alt: string } -const isImageObject = (img: unknown): img is ImageObject => { - return typeof img === 'object' && img !== null && 'src' in img && 'alt' in img -} +const section = 'Tags' --- -<BaseLayout pageTitle={pageTitle} path={path}> +<BaseLayout + description={pageDescription} + pageTitle={pageTitle} + path={path} + section={section} +> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <header class="text-center mb-16"> <h1 class="text-4xl md:text-5xl font-bold text-text mb-4"> @@ -94,162 +96,156 @@ const isImageObject = (img: unknown): img is ImageObject => { {/* Desktop: Show 3-4 cards in grid */} <div class="hidden md:grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> - {contentPreview.map(item => ( - <article class="bg-bg rounded-xl shadow-md hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> - <a - href={`/${item.type === 'case-study' ? 'case-studies' : item.type === 'service' ? 'services' : 'articles'}/${item.id}`} - class="block h-full" - > - {'image' in item.data && item.data.image && ( - <div class="aspect-video overflow-hidden"> - <img - src={ - typeof item.data.image === 'string' - ? item.data.image - : isImageObject(item.data.image) - ? item.data.image.src - : String(item.data.image) - } - alt={ - typeof item.data.image === 'string' - ? item.data.title - : isImageObject(item.data.image) - ? item.data.image.alt - : item.data.title - } - loading="lazy" - class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" - /> - </div> - )} + {contentPreview.map(item => { + const hrefBase = + item.type === 'case-study' ? 'case-studies' : item.type === 'service' ? 'services' : 'articles' + const href = `/${hrefBase}/${item.id}` - <div class="p-4 space-y-3"> - <div class="flex items-center gap-2 text-xs"> - <span - class={`px-2 py-1 rounded-full text-white font-medium ${ - item.type === 'article' - ? 'bg-primary' - : item.type === 'case-study' - ? 'bg-success' - : 'bg-accent' - }`} - > - {item.type === 'case-study' - ? 'Case Study' - : item.type.charAt(0).toUpperCase() + item.type.slice(1)} - </span> - <time class="text-text-muted"> - {item.data.publishDate.toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - })} - </time> - </div> + return ( + <article class="bg-bg rounded-xl shadow-md hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> + <a href={href} class="block h-full"> + {item.data.cover && ( + <div class="relative aspect-video overflow-hidden"> + <Picture + src={item.data.cover} + alt={item.data.coverAlt} + widths={[320, 640, 960, 1280]} + sizes="(min-width: 1280px) 25vw, (min-width: 768px) 33vw, 100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" + loading="lazy" + /> + </div> + )} - <h3 class="font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300 line-clamp-2"> - {item.data.title} - </h3> + <div class="p-4 space-y-3"> + <div class="flex items-center gap-2 text-xs"> + <span + class={`px-2 py-1 rounded-full text-white font-medium ${ + item.type === 'article' + ? 'bg-primary' + : item.type === 'case-study' + ? 'bg-success' + : 'bg-accent' + }`} + > + {item.type === 'case-study' + ? 'Case Study' + : item.type.charAt(0).toUpperCase() + item.type.slice(1)} + </span> + <time class="text-text-muted"> + {item.data.publishDate.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + })} + </time> + </div> - {item.data.description && ( - <p class="text-sm text-text-muted line-clamp-2"> - {item.data.description} - </p> - )} - </div> - </a> - </article> - ))} + <h3 class="font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300 line-clamp-2"> + {item.data.title} + </h3> + + {item.data.description && ( + <p class="text-sm text-text-muted line-clamp-2"> + {item.data.description} + </p> + )} + </div> + </a> + </article> + ) + })} </div> {/* Mobile: Show single card */} <div class="md:hidden"> - {contentPreview.slice(0, 1).map(item => ( - <article class="bg-bg rounded-xl shadow-md hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> - <a - href={`/${item.type === 'case-study' ? 'case-studies' : item.type === 'service' ? 'services' : 'articles'}/${item.id}`} - class="block" - > - {'image' in item.data && item.data.image && ( - <div class="aspect-video overflow-hidden"> - <img - src={ - typeof item.data.image === 'string' - ? item.data.image - : isImageObject(item.data.image) - ? item.data.image.src - : String(item.data.image) - } - alt={ - typeof item.data.image === 'string' - ? item.data.title - : isImageObject(item.data.image) - ? item.data.image.alt - : item.data.title - } - loading="lazy" - class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" - /> - </div> - )} - - <div class="p-6 space-y-3"> - <div class="flex items-center gap-2 text-sm"> - <span - class={`px-3 py-1 rounded-full text-white font-medium ${ - item.type === 'article' - ? 'bg-primary' - : item.type === 'case-study' - ? 'bg-success' - : 'bg-accent' - }`} - > - {item.type === 'case-study' - ? 'Case Study' - : item.type.charAt(0).toUpperCase() + item.type.slice(1)} - </span> - <time class="text-text-muted"> - {item.data.publishDate.toLocaleDateString('en-US', { - month: 'long', - year: 'numeric', - })} - </time> - </div> - - <h3 class="text-lg font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300"> - {item.data.title} - </h3> + {contentPreview.slice(0, 1).map(item => { + const hrefBase = + item.type === 'case-study' ? 'case-studies' : item.type === 'service' ? 'services' : 'articles' + const href = `/${hrefBase}/${item.id}` - {item.data.description && ( - <p class="text-text-muted line-clamp-3"> - {item.data.description} - </p> + return ( + <article class="bg-bg rounded-xl shadow-md hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden group"> + <a href={href} class="block"> + {item.data.cover && ( + <div class="relative aspect-video overflow-hidden"> + <Picture + src={item.data.cover} + alt={item.data.coverAlt} + widths={[640, 960, 1280]} + sizes="100vw" + formats={['avif', 'webp', 'jpeg']} + layout="constrained" + fit="cover" + position="center" + class="absolute inset-0 h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" + loading="lazy" + /> + </div> )} - <div class="pt-2"> - <span class="inline-flex items-center text-primary font-medium text-sm"> - View{' '} - {contentPreview.length > 1 - ? `all ${contentPreview.length} items` - : 'details'} - <svg - class="w-4 h-4 ml-1" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" + <div class="p-6 space-y-3"> + <div class="flex items-center gap-2 text-sm"> + <span + class={`px-3 py-1 rounded-full text-white font-medium ${ + item.type === 'article' + ? 'bg-primary' + : item.type === 'case-study' + ? 'bg-success' + : 'bg-accent' + }`} > - <path - stroke-linecap="round" - stroke-linejoin="round" - stroke-width="2" - d="M9 5l7 7-7 7" - /> - </svg> - </span> + {item.type === 'case-study' + ? 'Case Study' + : item.type.charAt(0).toUpperCase() + item.type.slice(1)} + </span> + <time class="text-text-muted"> + {item.data.publishDate.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })} + </time> + </div> + + <h3 class="text-lg font-semibold text-text leading-tight group-hover:text-primary transition-colors duration-300"> + {item.data.title} + </h3> + + {item.data.description && ( + <p class="text-text-muted line-clamp-3"> + {item.data.description} + </p> + )} + + <div class="pt-2"> + <span class="inline-flex items-center text-primary font-medium text-sm"> + View{' '} + {contentPreview.length > 1 + ? `all ${contentPreview.length} items` + : 'details'} + <svg + class="w-4 h-4 ml-1" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M9 5l7 7-7 7" + /> + </svg> + </span> + </div> </div> - </div> - </a> - </article> - ))} + </a> + </article> + ) + })} </div> </section> )