seo:gallery - image keywords

This commit is contained in:
lovebird 2025-03-08 09:57:38 +01:00
parent 8c9192e27a
commit 3f9798abca
7 changed files with 90 additions and 152 deletions

File diff suppressed because one or more lines are too long

View File

@ -23,8 +23,6 @@ export interface GalleryImage {
name?: string
url?: string
src: string
thumb?: string
responsive?: string
meta?: Meta
keywords?: string
description?: string

View File

@ -10,7 +10,6 @@ import { resolve } from '@polymech/commons'
import { files } from '@polymech/commons'
import { sync as exists } from '@polymech/fs/exists'
import { sync as read } from '@polymech/fs/read'
import { logger } from '@/base/index.js'
import { removeArrayValues, removeArrays, removeBufferValues, removeEmptyObjects } from '@/base/objects.js'
@ -58,38 +57,6 @@ export const image_url = async (src, fallback = DEFAULT_IMAGE_URL) => {
return safeSrc
}
const default_image = () => {
const url = image_url(DEFAULT_IMAGE_URL)
return {
name: "none",
src: url,
meta: {
format: '',
width: 0,
height: 0,
space: '',
channels: 0,
depth: 0,
density: 0,
chromaSubsampling: '',
isProgressive: false,
resolutionUnit: 0,
hasProfile: false,
hasAlpha: false,
orientation: 0,
exif: {},
json: {},
markdown: ""
},
keywords: [],
description: '',
alt: '',
width: 0,
height: 0,
title: '',
gps: { lon: '', lat: '' }
}
}
export const gallery = async (
assetPath,
product): Promise<GalleryImage[] | undefined> => {
@ -100,12 +67,12 @@ export const gallery = async (
const productConfig: any = read(PRODUCT_CONFIG(product), "json")
if (!productConfig) {
logger.warn(`ProductGallery : Product ${product} config not found !`)
logger.warn(`item gallery : item ${product} config not found !`)
return
}
const mediaPath = `${root}/${product}/${assetPath}/`
if (!exists(mediaPath)) {
logger.warn(`ProductGallery : Product ${product} media path not found ${mediaPath}!`)
logger.warn(`item gallery : item ${product} media path not found ${mediaPath}!`)
return []
}
const galleryGlob = (productConfig.gallery || {})[assetSlug]?.glob || IMAGES_GLOB
@ -125,14 +92,12 @@ export const gallery = async (
}
galleryFiles = default_sort(galleryFiles)
return await pMap(galleryFiles, async (file: string) => {
const parts = path.parse(file)
const filePath = path.join(mediaPath, file)
const meta_path_json = `${mediaPath}/${parts.name}.json`
const meta_json = exists(meta_path_json) ? read(meta_path_json, "json") as MetaJSON : { alt: "", keywords: "", title: "", description: "" }
const meta_path_md = `${mediaPath}/${parts.name}.md`
const meta_markdown = exists(meta_path_md) ? read(meta_path_md, "string") as string : "" as string
let imageMeta: any = await loadImage(filePath)
let exifRaw: any = null
try {
@ -141,8 +106,7 @@ export const gallery = async (
logger.error(`ProductGallery : Error loading exif data for ${filePath}`)
exifRaw = {}
}
const keywords = exifRaw?.['LastKeywordXMP']?.description || exifRaw?.iptc?.Keywords?.description || ''
const keywords = exifRaw?.['LastKeywordXMP']?.description || exifRaw?.iptc?.Keywords?.description || ''.split(',').map((k) => k.trim())
const exifDescription = exifRaw?.['ImageDescription']?.description || ''
const width = exifRaw?.['Image Width']?.value
const height = exifRaw?.['Image Height']?.value
@ -150,7 +114,7 @@ export const gallery = async (
const lat = exifRaw?.['GPSLatitude']?.description
const title = exifRaw?.title?.description || meta_json.title || ''
const description = meta_json.description || meta_markdown || exifDescription || exifRaw?.iptc?.['Caption/Abstract'].description || ''
const alt = meta_json.description || meta_markdown || exifDescription || exifRaw?.iptc?.['Caption/Abstract'].description || ''
imageMeta.exif = exifRaw
imageMeta = removeBufferValues(imageMeta)
imageMeta = removeArrayValues(imageMeta)
@ -161,8 +125,6 @@ export const gallery = async (
delete imageMeta.exif.icc
delete imageMeta.exif.xmp
delete imageMeta.exif.iptc
const keywordsTranslated = ''
const assetUrl = (filePath) => {
return sanitizeUri(ITEM_ASSET_URL(
{
@ -178,8 +140,6 @@ export const gallery = async (
name: path.parse(file).name,
url: await image_url(assetUrl(file)),
src: await image_url(assetUrl(file)),
thumb: assetUrl(`/${parts.name}-thumb.webp`),
responsive: assetUrl(`/webp/${parts.name}.webp`),
meta: {
format: imageMeta.format,
width: imageMeta.width,
@ -198,15 +158,14 @@ export const gallery = async (
json: meta_json as MetaJSON,
markdown: meta_markdown as string
},
keywords: keywords.split(',').map((k) => k.trim()),
description,
alt: `${description} - ${keywordsTranslated || ''}`,
keywords,
description: alt,
alt,
width,
height,
title,
gps: { lon, lat }
}
return ret
})
}

View File

@ -1,45 +1,42 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { createComponent } from "astro/runtime/server/astro-component.js";
import { renderTemplate, unescapeHTML } from "astro/runtime/server/index.js";
import { findUp } from 'find-up'
import { createLogger } from '@polymech/log'
import { parse, IProfile, IComponentConfig } from '@polymech/commons'
import { renderMarkup } from "@/model/component.js";
import { IComponentConfig } from '@polymech/commons'
import { sync as read } from '@polymech/fs/read'
import { sync as exists } from '@polymech/fs/exists'
import {
LOGGING_NAMESPACE,
OSRL_ENV,
OSRL_PRODUCT_PROFILE,
PRODUCT_ROOT,
I18N_SOURCE_LANGUAGE
} from 'config/config.js'
import { I18N_SOURCE_LANGUAGE } from 'config/config.js'
import { translate } from '@/base/i18n.js'
import { item_defaults } from '@/base/index.js'
import config from "../app/config.json" with { "type": "json" }
export const item_keywords = async (item: IComponentConfig, locale: string = I18N_SOURCE_LANGUAGE) => {
let system_keywords = ""
const keywords = (keywords: string) => keywords.split(',').map(k => k.trim()).filter(Boolean);
const unique = (...keywordGroups: string[][]) => {
return Array.from(new Set(keywordGroups.flat()))
};
export const site_keywords = async (locale: string = I18N_SOURCE_LANGUAGE) => {
const configKeywords = keywords(config.metadata.keywords || "");
const allKeywords = unique(configKeywords)
const system_keywords = await translate(allKeywords.join(','), I18N_SOURCE_LANGUAGE, locale);
const keywordsArray = keywords(system_keywords)
return keywordsArray.join(',');
};
export const item_keywords = async (item: IComponentConfig | null, locale: string = I18N_SOURCE_LANGUAGE) => {
if (!item) {
return (await site_keywords(locale))
}
let system_keywords = "";
if (item.PRODUCT_ROOT) {
const defaultsJson = await item_defaults(item.PRODUCT_ROOT);
let defaults: Record<string, string> = defaultsJson ? read(defaultsJson, 'json') as Record<string, string> || {} : {}
const defaultsKeywords = (defaults.keywords || "").split(',').map(k => k.trim()).filter(Boolean)
const configKeywords = (config.metadata.keywords || "").split(',').map(k => k.trim()).filter(Boolean)
const itemKeywords = (item.keywords || "").split(',').map(k => k.trim()).filter(Boolean)
const allKeywords = Array.from(new Set([
...defaultsKeywords,
...configKeywords,
...itemKeywords
])).join(',');
system_keywords = await translate(allKeywords, I18N_SOURCE_LANGUAGE, locale)
const defaults: Record<string, string> = defaultsJson ? read(defaultsJson, 'json') as Record<string, string> || {} : {}
const defaultsKeywords = keywords(defaults.keywords || "")
const configKeywords = await site_keywords(locale)
const itemKeywords = keywords(item.keywords || "")
const allKeywords = unique(defaultsKeywords, configKeywords.split(','), itemKeywords)
system_keywords = await translate(allKeywords.join(''), I18N_SOURCE_LANGUAGE, locale)
}
const keywordsArray = [...new Set([item.name, ...system_keywords.split(',')])]
const keywords = keywordsArray.join(',')
return keywords
}
const keywordsArray = unique([item.name], keywords(system_keywords))
return keywordsArray.join(',')
};

View File

@ -1,21 +1,27 @@
---
import { Img } from "imagetools/components";
import Translate from "@/components/polymech/i18n.astro"
import { translate } from "@/base/i18n";
import { createMarkdownComponent, markdownToHtml } from "@/base/index.js";
import { translate } from "@/base/i18n"
import { item_keywords, site_keywords } from '@/base/seo.js'
import pMap from 'p-map'
import { createMarkdownComponent, markdownToHtml } from "@/base/index.js"
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js"
import { IComponentConfig } from "@polymech/commons"
interface Image {
alt: string
src: string
title?: string
description?: string
item?:IComponentConfig
}
export interface Props {
images: Image[];
id?: string;
siteKeywords?: string[];
item?: IComponentConfig;
gallerySettings?: {
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
@ -31,47 +37,24 @@ export interface Props {
SHOW_DESCRIPTION?: boolean;
};
}
const { images, gallerySettings = {}, lightboxSettings = {}, siteKeywords = [] } = Astro.props;
const { images, gallerySettings = {}, lightboxSettings = {}, item } = Astro.props;
const mergedGallerySettings = {
SIZES_REGULAR: gallerySettings.SIZES_REGULAR || IMAGE_SETTINGS.GALLERY.SIZES_REGULAR,
SIZES_THUMB: gallerySettings.SIZES_THUMB || IMAGE_SETTINGS.GALLERY.SIZES_THUMB,
SHOW_TITLE: gallerySettings.SHOW_TITLE ?? IMAGE_SETTINGS.GALLERY.SHOW_TITLE,
SHOW_DESCRIPTION: gallerySettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.GALLERY.SHOW_DESCRIPTION,
};
}
const mergedLightboxSettings = {
SIZES_LARGE: lightboxSettings.SIZES_LARGE || IMAGE_SETTINGS.LIGHTBOX.SIZES_LARGE,
SHOW_TITLE: lightboxSettings.SHOW_TITLE ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_TITLE,
SHOW_DESCRIPTION: lightboxSettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_DESCRIPTION,
};
const locale = Astro.currentLocale || "en";
// Function to augment alt text with keywords
const augmentAltText = (altText, keywords) => {
if (!keywords || keywords.length === 0) return altText;
// Filter out keywords that are already in the alt text
const filteredKeywords = keywords.filter(keyword =>
keyword && keyword.trim() !== '' && !altText.toLowerCase().includes(keyword.toLowerCase())
);
// If no keywords to add, return original alt text
if (filteredKeywords.length === 0) return altText;
// Add keywords to alt text
return `${altText} (${filteredKeywords.join(', ')})`;
};
// Pre-calculate translated alt text for all images with augmented keywords
const translatedAltTexts = await Promise.all(
images.map(async (image) => {
// Augment alt text with site keywords before translation
const augmentedAltText = augmentAltText(image.alt, siteKeywords);
return await translate(augmentedAltText, I18N_SOURCE_LANGUAGE, locale);
})
);
}
const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE
const alt = async (altText) => `${altText} - ${await item_keywords(item, locale)}`
const alt_translated = await pMap(images, async (image) => {
const augmentedAltText = await alt(image.alt)
return await translate(augmentedAltText, I18N_SOURCE_LANGUAGE, locale)
}, { concurrency: 1 })
---
@ -145,7 +128,7 @@ const translatedAltTexts = await Promise.all(
<div x-show={`currentIndex === ${index}`} key={index} class="w-full h-full flex items-center justify-center">
<Img
src={image.src}
alt={translatedAltTexts[index]}
alt={alt_translated[index]}
objectFit="contain"
format="avif"
placeholder="blurred"
@ -185,7 +168,7 @@ const translatedAltTexts = await Promise.all(
placeholder="blurred"
sizes={mergedGallerySettings.SIZES_THUMB}
class="w-32 h-32 p-1 object-contain rounded hover:ring-2 hover:ring-blue-500"
alt={translatedAltTexts[index]}
alt={alt_translated[index]}
attributes={{
img: {
class: "w-32 h-32 rounded-lg hover:ring-2 hover:ring-blue-500 thumbnail-img aspect-square",
@ -215,7 +198,7 @@ const translatedAltTexts = await Promise.all(
<div x-show={`currentIndex === ${index}`} key={index}>
<Img
src={image.src}
alt={translatedAltTexts[index]}
alt={alt_translated[index]}
placeholder="blurred"
format="avif"
objectFit="contain"

View File

@ -2,8 +2,9 @@
import { slugify } from "@/base/strings.js";
export interface Props {
title: string;
class?: string;
}
const { title = "title" } = Astro.props;
const { title = "title", class:className = "" } = Astro.props;
const slug = `${slugify(title)}-tab-content`;
const label = `${slug}-tab`;
const view = `${slugify(title)}-view`;
@ -14,6 +15,4 @@ const view = `${slugify(title)}-view`;
id={view}
role="tabpanel"
aria-labelledby={label}
>
<slot />
</div>
> <slot /></div>

View File

@ -33,9 +33,9 @@ import {
isRTL,
} from "config/config.js";
const { frontmatter: data, ...rest } = Astro.props;
const { frontmatter: item, ...rest } = Astro.props;
const content = await translate(
data.content || "",
item.content || "",
I18N_SOURCE_LANGUAGE,
Astro.currentLocale,
);
@ -45,7 +45,7 @@ const str_debug =
"```json\n" +
JSON.stringify(
{
...data,
...item,
config: null,
},
null,
@ -56,7 +56,7 @@ const str_debug =
const Content_Debug = await createMarkdownComponent(str_debug);
---
<BaseLayout frontmatter={data} description={data.description} {...rest}>
<BaseLayout frontmatter={item} description={item.description} {...rest}>
<Wrapper>
<section>
<div class="grid sm:grid-cols-2 lg:grid-cols-2 just xl:grid-cols-2 gap-2">
@ -66,11 +66,11 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<h1
class="text-neutral-500 font-mono font-semibold mb-2 text-2xl"
>
<span><Translate>{`${data.title}`}</Translate></span>
<span><Translate>{`${item.title}`}</Translate></span>
{
isRTL(Astro.currentLocale) && (
<div class="text-neutral-500 font-mono font-semibold mb-2">
"{data.title}"
"{item.title}"
</div>
)
}
@ -82,12 +82,12 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<div class="gap-2 flex flex-col h-full justify-end">
{
SHOW_3D_PREVIEW &&
data.Preview3d &&
data.cad &&
data.cad[0] &&
data.cad[0][".html"] && (
item.Preview3d &&
item.cad &&
item.cad[0] &&
item.cad[0][".html"] && (
<a
href={data.cad[0][".html"]}
href={item.cad[0][".html"]}
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 font-mono h-14 flex space-x-6 items-center bg-white hover:bg-neutral-200 duration-300 rounded-xl w-full justify-between"
@ -136,9 +136,9 @@ const Content_Debug = await createMarkdownComponent(str_debug);
)
}
{
SHOW_CHECKOUT && data.checkout && (
SHOW_CHECKOUT && item.checkout && (
<a
href={data.checkout}
href={item.checkout}
title="link to your page"
aria-label="your label"
class="relative group overflow-hidden pl-4 font-mono h-14 flex space-x-6 items-center bg-orange-500 hover:bg-black duration-300 rounded-xl w-full justify-between"
@ -195,7 +195,7 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<Translate>License</Translate>
</h3>
<p class="text-neutral-500 mt-4 text-sm">
{data.license || DEFAULT_LICENSE}
{item.license || DEFAULT_LICENSE}
</p>
</div>
</div>
@ -209,8 +209,9 @@ const Content_Debug = await createMarkdownComponent(str_debug);
style="height: 100%; width: 100%;"
>
<GalleryK
images={data.assets.renderings}
images={item.assets.renderings}
gallerySettings={{ SHOW_TITLE: false }}
item={item}
/>
</div>
)
@ -219,11 +220,11 @@ const Content_Debug = await createMarkdownComponent(str_debug);
</section>
{
data.assets.showcase && data.assets.showcase.length > 0 && (
item.assets.showcase && item.assets.showcase.length > 0 && (
<section>
<div class="mb-2 md:mb-16 mt-0 md:mt-16 p-2 md:p-4 border-b border-gray-200 dark:border-gray-700">
<GalleryK
images={data.assets.showcase}
images={item.assets.showcase}
lightboxSettings={{
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
@ -233,6 +234,7 @@ const Content_Debug = await createMarkdownComponent(str_debug);
SHOW_TITLE: false,
SHOW_DESCRIPTION: false,
}}
item={item}
/>
</div>
</section>
@ -260,28 +262,28 @@ const Content_Debug = await createMarkdownComponent(str_debug);
<div id="default-styled-tab-content">
<TabContent title="Overview" class="content">
{
SHOW_README && data.readme && (
<Readme markdown={data.readme} data={data} />
SHOW_README && item.readme && (
<Readme markdown={item.readme} data={item} />
)
}
</TabContent>
<TabContent title="Specs" class="bg-white rounded-xl dark:bg-gray-800 font-mono">
<Specs frontmatter={data} />
<Specs frontmatter={item} />
</TabContent>
<TabContent title="Gallery" class="p-0 md:p-4 rounded-lg bg-white">
<GalleryK images={data.assets.gallery} />
<GalleryK images={item.assets.gallery} item={item} />
</TabContent>
{
SHOW_SAMPLES && (
<TabContent title="Samples" class="p-4 bg-white rounded-xl dark:bg-gray-800">
<GalleryK images={data.assets.samples} />
<GalleryK images={item.assets.samples} item={item} />
</TabContent>
)
}
{
SHOW_RESOURCES && (
<TabContent title="Resources" class="p-4 bg-white rounded-xl dark:bg-gray-800">
<Resources frontmatter={data} />
<Resources frontmatter={item} />
</TabContent>
)
}
@ -315,7 +317,7 @@ const Content_Debug = await createMarkdownComponent(str_debug);
);
if (tabTrigger) {
setTimeout(() => {
tabTrigger.click();
(tabTrigger as HTMLElement).click();
}, 100);
}
}