diff --git a/src/App.tsx b/src/App.tsx index 0f32bc9..a58328a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { Paper, Chip, Link, + Switch, } from "@mui/material"; import { Language as LanguageIcon, @@ -33,8 +34,14 @@ import { Settings as SettingsIcon, Update as UpdateIcon, Verified as VerifiedIcon, + ExpandMore as ExpandMoreIcon, } from "@mui/icons-material"; -import code, { CodeData, ImageOptions } from "./code"; +import code, { + CodeData, + ImageOptions, + PageMetadata, + StructuredDataOptions, +} from "./code"; import "./styles.css"; const DEFAULT_DOMAIN = "worknot.classmethod.cf"; @@ -125,6 +132,18 @@ export default function App() { const [optionImage, setOptionImage] = useState({}); const [optionalImageResize, setOptionalImageResize] = useState(false); const [copied, setCopied] = useState(false); + const [pageMetadata, setPageMetadata] = useState< + Record + >({}); + const [structuredData, setStructuredData] = useState({ + enabled: false, + schemaType: "WebPage", + organizationName: "", + logoUrl: "", + }); + const [slugMetadataExpanded, setSlugMetadataExpanded] = useState< + Record + >({}); function createInputHandler( setter: React.Dispatch>, @@ -149,12 +168,31 @@ export default function App() { } function deleteSlug(index: number): void { + const slug = slugs[index][0]; setSlugs(slugs.filter((_, i) => i !== index)); + // Clean up page metadata for the deleted slug + if (slug && pageMetadata[slug]) { + const newMetadata = { ...pageMetadata }; + delete newMetadata[slug]; + setPageMetadata(newMetadata); + } + // Clean up expanded state + const newExpanded = { ...slugMetadataExpanded }; + delete newExpanded[index]; + setSlugMetadataExpanded(newExpanded); setCopied(false); } function handleCustomURL(value: string, index: number): void { + const oldSlug = slugs[index][0]; setSlugs(updateSlugAtIndex(slugs, index, 0, value)); + // Update page metadata key when slug changes + if (oldSlug !== value && pageMetadata[oldSlug]) { + const newMetadata = { ...pageMetadata }; + newMetadata[value] = newMetadata[oldSlug]; + delete newMetadata[oldSlug]; + setPageMetadata(newMetadata); + } setCopied(false); } @@ -167,6 +205,39 @@ export default function App() { setOptional(!optional); } + function handlePageMetadata( + slug: string, + field: keyof PageMetadata, + value: string, + ): void { + setPageMetadata({ + ...pageMetadata, + [slug]: { + ...pageMetadata[slug], + [field]: value, + }, + }); + setCopied(false); + } + + function handleStructuredDataChange( + field: keyof StructuredDataOptions, + value: string | boolean, + ): void { + setStructuredData({ + ...structuredData, + [field]: value, + }); + setCopied(false); + } + + function toggleSlugMetadata(index: number): void { + setSlugMetadataExpanded({ + ...slugMetadataExpanded, + [index]: !slugMetadataExpanded[index], + }); + } + function clampValue(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -219,6 +290,8 @@ export default function App() { customScript, customCss, optionImage, + pageMetadata, + structuredData, }; const script = noError ? code(codeData) : undefined; @@ -460,16 +533,96 @@ export default function App() { variant="outlined" size="small" /> - + {customUrl && ( + + )} + + - Remove - + + + Custom metadata for /{customUrl} + + + handlePageMetadata(customUrl, "title", e.target.value) + } + value={pageMetadata[customUrl]?.title || ""} + variant="outlined" + size="small" + /> + + handlePageMetadata( + customUrl, + "description", + e.target.value, + ) + } + value={pageMetadata[customUrl]?.description || ""} + variant="outlined" + size="small" + /> + + handlePageMetadata(customUrl, "ogImage", e.target.value) + } + value={pageMetadata[customUrl]?.ogImage || ""} + variant="outlined" + size="small" + /> + + ))} @@ -557,6 +710,95 @@ export default function App() { variant="outlined" size="small" /> + + + + + + JSON-LD Structured Data + + + Enable rich snippets in search results + + + + handleStructuredDataChange("enabled", e.target.checked) + } + /> + + + + + Schema Type + + + + handleStructuredDataChange( + "organizationName", + e.target.value, + ) + } + value={structuredData.organizationName} + variant="outlined" + size="small" + /> + + handleStructuredDataChange("logoUrl", e.target.value) + } + value={structuredData.logoUrl} + variant="outlined" + size="small" + /> + + Structured data helps search engines understand your + content and display rich results. Validate with{" "} + + Google Rich Results Test + + . + + + + diff --git a/src/code.ts b/src/code.ts index 829dd36..70c59f2 100644 --- a/src/code.ts +++ b/src/code.ts @@ -10,6 +10,19 @@ export interface ImageOptions { imageMetadata?: string; } +export interface PageMetadata { + title?: string; + description?: string; + ogImage?: string; +} + +export interface StructuredDataOptions { + enabled: boolean; + schemaType: "WebPage" | "Article" | "Organization"; + organizationName?: string; + logoUrl?: string; +} + export interface CodeData { myDomain: string; notionUrl: string; @@ -20,6 +33,8 @@ export interface CodeData { customScript: string; customCss: string; optionImage: ImageOptions; + pageMetadata: Record; + structuredData: StructuredDataOptions; } function getId(url: string): string { @@ -43,6 +58,8 @@ export default function code(data: CodeData): string { customScript, customCss, optionImage, + pageMetadata, + structuredData, } = data; let url = myDomain.replace("https://", "").replace("http://", ""); if (url.slice(-1) === "/") url = url.slice(0, url.length - 1); @@ -71,6 +88,21 @@ ${slugs const PAGE_TITLE = '${pageTitle || ""}'; const PAGE_DESCRIPTION = '${pageDescription || ""}'; + /* + * Step 3.1: enter per-page metadata for better SEO (optional) + * Each key is a slug, each value contains title, description, and ogImage + */ + const PAGE_METADATA = ${JSON.stringify(pageMetadata || {}, null, 4).replace(/\n/g, "\n ")}; + + /* + * Step 3.2: structured data configuration for rich search results (optional) + * Enable to add JSON-LD schema markup to your pages + */ + const STRUCTURED_DATA_ENABLED = ${structuredData?.enabled || false}; + const SCHEMA_TYPE = '${structuredData?.schemaType || "WebPage"}'; + const ORGANIZATION_NAME = '${structuredData?.organizationName || ""}'; + const LOGO_URL = '${structuredData?.logoUrl || ""}'; + /* Step 4: enter a Google Font name, you can choose from https://fonts.google.com */ const GOOGLE_FONT = '${googleFont || ""}'; @@ -307,8 +339,14 @@ ${slugs class MetaRewriter { constructor(slug) { this.slug = slug; + // Get page-specific metadata or use defaults (Issue #11) + this.metadata = PAGE_METADATA[slug] || {}; } element(element) { + const pageTitle = this.metadata.title || PAGE_TITLE; + const pageDescription = this.metadata.description || PAGE_DESCRIPTION; + const ogImage = this.metadata.ogImage; + // Remove noindex meta tag for SEO (Issue #8) if (element.getAttribute('name') === 'robots') { const content = element.getAttribute('content'); @@ -322,22 +360,27 @@ ${slugs element.setAttribute('content', MY_DOMAIN); } } - if (PAGE_TITLE !== '') { + if (pageTitle !== '') { if (element.getAttribute('property') === 'og:title' || element.getAttribute('name') === 'twitter:title') { - element.setAttribute('content', PAGE_TITLE); + element.setAttribute('content', pageTitle); } if (element.tagName === 'title') { - element.setInnerContent(PAGE_TITLE); + element.setInnerContent(pageTitle); } } - if (PAGE_DESCRIPTION !== '') { + if (pageDescription !== '') { if (element.getAttribute('name') === 'description' || element.getAttribute('property') === 'og:description' || element.getAttribute('name') === 'twitter:description') { - element.setAttribute('content', PAGE_DESCRIPTION); + element.setAttribute('content', pageDescription); } } + // Set custom OG image if specified (Issue #11) + if (ogImage && (element.getAttribute('property') === 'og:image' + || element.getAttribute('name') === 'twitter:image')) { + element.setAttribute('content', ogImage); + } // Set canonical URL for og:url and twitter:url (Issue #9) if (element.getAttribute('property') === 'og:url' || element.getAttribute('name') === 'twitter:url') { @@ -353,6 +396,8 @@ ${slugs class HeadRewriter { constructor(slug) { this.slug = slug; + // Get page-specific metadata or use defaults (Issue #11) + this.metadata = PAGE_METADATA[slug] || {}; } element(element) { // Add canonical URL and robots meta tag for SEO (Issue #8 & #9) @@ -360,6 +405,41 @@ ${slugs element.append(\`\`, { html: true }); element.append(\`\`, { html: true }); + // Add JSON-LD structured data for rich search results (Issue #10) + if (STRUCTURED_DATA_ENABLED) { + const pageTitle = this.metadata.title || PAGE_TITLE; + const pageDescription = this.metadata.description || PAGE_DESCRIPTION; + const structuredData = { + "@context": "https://schema.org", + "@type": SCHEMA_TYPE, + "name": pageTitle, + "description": pageDescription, + "url": canonicalUrl + }; + // Add publisher info for Article schema type + if (SCHEMA_TYPE === 'Article' || SCHEMA_TYPE === 'WebPage') { + structuredData.publisher = { + "@type": "Organization", + "name": ORGANIZATION_NAME || MY_DOMAIN, + "url": \`https://\${MY_DOMAIN}\` + }; + if (LOGO_URL) { + structuredData.publisher.logo = { + "@type": "ImageObject", + "url": LOGO_URL + }; + } + } + // Add Organization-specific fields + if (SCHEMA_TYPE === 'Organization') { + structuredData.name = ORGANIZATION_NAME || MY_DOMAIN; + if (LOGO_URL) { + structuredData.logo = LOGO_URL; + } + } + element.append(\`\`, { html: true }); + } + if (GOOGLE_FONT !== '') { element.append(\` \`, {