diff --git a/packages/polymech/package.json b/packages/polymech/package.json index 42ad866..144f8fa 100644 --- a/packages/polymech/package.json +++ b/packages/polymech/package.json @@ -7,6 +7,7 @@ }, "exports": { ".": "./dist/index.js", + "./base/*": "./dist/base/*", "./components/*": "./src/components/*" }, "files": [ diff --git a/packages/polymech/src/base/specs.ts b/packages/polymech/src/base/specs.ts new file mode 100644 index 0000000..67c20f2 --- /dev/null +++ b/packages/polymech/src/base/specs.ts @@ -0,0 +1,273 @@ +import { parse } from 'node-xlsx' +import { sync as exists } from '@polymech/fs/exists' +import pkg from 'showdown' +const { Converter } = pkg + +export const md2html = (content) => { + let converter = new Converter({ tables: true }); + converter.setOption('literalMidWordUnderscores', 'true'); + return converter.makeHtml(content); +} + +/** + * @typedef MarkdownTableOptions + * @property {string|null|Array.} [align] + * @property {boolean} [padding=true] + * @property {boolean} [delimiterStart=true] + * @property {boolean} [delimiterStart=true] + * @property {boolean} [delimiterEnd=true] + * @property {boolean} [alignDelimiters=true] + * @property {(value: string) => number} [stringLength] + */ + +/** + * Create a table from a matrix of strings. + * + * from : https://github.com/wooorm/markdown-table/blob/main/index.js + * + * + * + * @param {Array.>} table + * @param {MarkdownTableOptions} [options] + * @returns {string} + */ +export const markdownTable = (table, options: any = {}) => { + + const align = (options.align || []).concat() + const stringLength = options.stringLength || defaultStringLength + /** @type {Array} Character codes as symbols for alignment per column. */ + const alignments: number[] = [] + /** @type {Array>} Cells per row. */ + const cellMatrix: string[][] = [] + /** @type {Array>} Sizes of each cell per row. */ + const sizeMatrix: number[][] = [] + /** @type {Array} */ + const longestCellByColumn: number[] = [] + let mostCellsPerRow = 0 + let rowIndex = -1 + + // This is a superfluous loop if we don’t align delimiters, but otherwise we’d + // do superfluous work when aligning, so optimize for aligning. + while (++rowIndex < table.length) { + /** @type {Array} */ + const row: string[] = [] + /** @type {Array} */ + const sizes: number[] = [] + let columnIndex = -1 + + if (table[rowIndex].length > mostCellsPerRow) { + mostCellsPerRow = table[rowIndex].length + } + + while (++columnIndex < table[rowIndex].length) { + const cell = serialize(table[rowIndex][columnIndex]) + + if (options.alignDelimiters !== false) { + const size = stringLength(cell) + sizes[columnIndex] = size + + if ( + longestCellByColumn[columnIndex] === undefined || + size > longestCellByColumn[columnIndex] + ) { + longestCellByColumn[columnIndex] = size + } + } + + row.push(cell) + } + + cellMatrix[rowIndex] = row + sizeMatrix[rowIndex] = sizes + } + + // Figure out which alignments to use. + let columnIndex = -1 + + if (typeof align === 'object' && 'length' in align) { + while (++columnIndex < mostCellsPerRow) { + alignments[columnIndex] = toAlignment(align[columnIndex]) + } + } else { + const code = toAlignment(align) + + while (++columnIndex < mostCellsPerRow) { + alignments[columnIndex] = code + } + } + + // Inject the alignment row. + columnIndex = -1 + /** @type {Array} */ + const row: string[] = [] + /** @type {Array} */ + const sizes: number[] = [] + + while (++columnIndex < mostCellsPerRow) { + const code = alignments[columnIndex] + let before = '' + let after = '' + + if (code === 99 /* `c` */) { + before = ':' + after = ':' + } else if (code === 108 /* `l` */) { + before = ':' + } else if (code === 114 /* `r` */) { + after = ':' + } + + // There *must* be at least one hyphen-minus in each alignment cell. + let size = + options.alignDelimiters === false + ? 1 + : Math.max( + 1, + longestCellByColumn[columnIndex] - before.length - after.length + ) + + const cell = before + '-'.repeat(size) + after + + if (options.alignDelimiters !== false) { + size = before.length + size + after.length + + if (size > longestCellByColumn[columnIndex]) { + longestCellByColumn[columnIndex] = size + } + + sizes[columnIndex] = size + } + + row[columnIndex] = cell + } + + // Inject the alignment row. + cellMatrix.splice(1, 0, row) + sizeMatrix.splice(1, 0, sizes) + + rowIndex = -1 + /** @type {Array} */ + const lines: string[] = [] + + while (++rowIndex < cellMatrix.length) { + const row = cellMatrix[rowIndex] + const sizes = sizeMatrix[rowIndex] + columnIndex = -1 + /** @type {Array} */ + const line: string[] = [] + + while (++columnIndex < mostCellsPerRow) { + const cell = row[columnIndex] || '' + let before = '' + let after = '' + + if (options.alignDelimiters !== false) { + const size = + longestCellByColumn[columnIndex] - (sizes[columnIndex] || 0) + const code = alignments[columnIndex] + + if (code === 114 /* `r` */) { + before = ' '.repeat(size) + } else if (code === 99 /* `c` */) { + if (size % 2) { + before = ' '.repeat(size / 2 + 0.5) + after = ' '.repeat(size / 2 - 0.5) + } else { + before = ' '.repeat(size / 2) + after = before + } + } else { + after = ' '.repeat(size) + } + } + + if (options.delimiterStart !== false && !columnIndex) { + line.push('|') + } + + if ( + options.padding !== false && + // Don’t add the opening space if we’re not aligning and the cell is + // empty: there will be a closing space. + !(options.alignDelimiters === false && cell === '') && + (options.delimiterStart !== false || columnIndex) + ) { + line.push(' ') + } + + if (options.alignDelimiters !== false) { + line.push(before) + } + + line.push(cell) + + if (options.alignDelimiters !== false) { + line.push(after) + } + + if (options.padding !== false) { + line.push(' ') + } + + if ( + options.delimiterEnd !== false || + columnIndex !== mostCellsPerRow - 1 + ) { + line.push('|') + } + } + + lines.push( + options.delimiterEnd === false + ? line.join('').replace(/ +$/, '') + : line.join('') + ) + } + + return lines.join('\n') +} + +/** + * @param {string|null|undefined} [value] + * @returns {string} + */ +function serialize(value) { + return value === null || value === undefined ? '' : String(value) +} + +/** + * @param {string} value + * @returns {number} + */ +function defaultStringLength(value) { + return value.length +} + +/** + * @param {string|null|undefined} value + * @returns {number} + */ +function toAlignment(value) { + const code = typeof value === 'string' ? value.codePointAt(0) : 0 + + return code === 67 /* `C` */ || code === 99 /* `c` */ + ? 99 /* `c` */ + : code === 76 /* `L` */ || code === 108 /* `l` */ + ? 108 /* `l` */ + : code === 82 /* `R` */ || code === 114 /* `r` */ + ? 114 /* `r` */ + : 0 +} + + +export const specs = (path: string) => { + if (!path || !exists(path)) { + return ''; + } else { + let data = parse(path) as any; + data[0].data = data[0].data.filter((d) => !!d.length); + data = markdownTable(data[0].data); + const ret = md2html(data); + return ret + } +} diff --git a/packages/polymech/src/components/Default.astro b/packages/polymech/src/components/Default.astro new file mode 100644 index 0000000..86c8103 --- /dev/null +++ b/packages/polymech/src/components/Default.astro @@ -0,0 +1,7 @@ +--- +import { createComponent } from "astro/runtime/server/astro-component.js"; +import { renderTemplate, unescapeHTML } from "astro/runtime/server/index.js"; +const DefaultComponent = createComponent(() => renderTemplate( unescapeHTML(`
`), [])); +--- + + diff --git a/packages/polymech/src/components/GalleryK.astro b/packages/polymech/src/components/GalleryK.astro new file mode 100644 index 0000000..448f246 --- /dev/null +++ b/packages/polymech/src/components/GalleryK.astro @@ -0,0 +1,229 @@ +--- +import { Img } from "imagetools/components"; +import Translate from "./i18n.astro" + +import { translate } from "@/base/i18n"; +import { createMarkdownComponent, markdownToHtml } from "@/base/index.js"; + +import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js" + +interface Image { + alt: string + src: string + title?: string + description?: string +} + +export interface Props { + images: Image[]; + id?: string; + gallerySettings?: { + SIZES_REGULAR?: string; + SIZES_THUMB?: string; + SIZES_LARGE?: string; + SHOW_TITLE?: boolean; + SHOW_DESCRIPTION?: boolean; + }; + lightboxSettings?: { + SIZES_REGULAR?: string; + SIZES_THUMB?: string; + SIZES_LARGE?: string; + SHOW_TITLE?: boolean; + SHOW_DESCRIPTION?: boolean; + }; +} + +const { images, gallerySettings = {}, lightboxSettings = {} } = 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"; + +--- + +
= this.minSwipeDistance) { + if (swipeDistance > 0 && this.currentIndex > 0) { + // Swiped right, show previous image + this.currentIndex--; + } else if (swipeDistance < 0 && this.currentIndex < this.total - 1) { + // Swiped left, show next image + this.currentIndex++; + } + } + this.isSwiping = false; + }, + preloadAndOpen() { + if (this.isSwiping) return; + this.lightboxLoaded = false; + let img = new Image(); + img.src = this.images[this.currentIndex].src; + img.onload = () => { + this.lightboxLoaded = true; + this.open = true; + }; + } + } + `} + @keydown.escape.window="open = false" + @keydown.window="if(open){ + if($event.key === 'ArrowRight' && currentIndex < total - 1){ + currentIndex++; + lightboxLoaded = false; + preloadAndOpen(); + } else if($event.key === 'ArrowLeft' && currentIndex > 0){ + currentIndex--; + lightboxLoaded = false; + preloadAndOpen(); + } + }" + class="product-gallery">
+ +
+ {images.map((image, index) => ( +
+ {image.alt,I18N_SOURCE_LANGUAGE,locale} +
+ ))} +
+ + { (mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && ( +
+ {images.map((image, index) => ( +
+ { mergedGallerySettings.SHOW_TITLE && (

{image.title}

)} + { mergedGallerySettings.SHOW_DESCRIPTION && (

{ image.description}

)} +
+ ))} +
+ )} + +
+
+ {images.map((image, index) => ( + + ))} +
+
+
+ + \ No newline at end of file diff --git a/packages/polymech/src/components/i18n.astro b/packages/polymech/src/components/i18n.astro new file mode 100644 index 0000000..0265458 --- /dev/null +++ b/packages/polymech/src/components/i18n.astro @@ -0,0 +1,21 @@ +--- +import { I18N_SOURCE_LANGUAGE } from "config/config.js" +import { translate, IOptions } from '@/base/i18n.js' + +export interface Props extends IOptions { + language?: string, + clazz?:string +} + +const { + language = Astro.currentLocale, + clazz = '', + ...rest +} = Astro.props + +const content = await Astro.slots.render('default') +const translatedText = await translate(content, I18N_SOURCE_LANGUAGE, language, rest) +--- +
+ {translatedText} +
diff --git a/packages/polymech/src/components/specs.astro b/packages/polymech/src/components/specs.astro new file mode 100644 index 0000000..0fbacf4 --- /dev/null +++ b/packages/polymech/src/components/specs.astro @@ -0,0 +1,43 @@ +--- +import * as path from "path" +import { sync as fileExists } from "@polymech/fs/exists" + +import { specs } from "@/base/specs.js" + +import { render, logger } from "@/base/index.js" +import { translateSheets } from "@/base/i18n.js" +import { I18N_SOURCE_LANGUAGE, LANGUAGES, PRODUCT_SPECS } from "config/config.js" + +import DefaultComponent from "./Default.astro" + +const { frontmatter: data } = Astro.props +const locale = Astro.currentLocale || I18N_SOURCE_LANGUAGE + +const specs_table = async (relPath) => +{ + let specsPath = path.join(PRODUCT_SPECS(relPath)); + if (!fileExists(specsPath)) { + logger.debug(`No specs found for ${specsPath}`); + return null; + } + if (locale !== I18N_SOURCE_LANGUAGE && LANGUAGES.includes(locale)) { + const i18n = await translateSheets(relPath, locale); + if (!i18n) { + logger.debug(`No i18n found for ${relPath} : ${locale}`); + } else { + specsPath = i18n + } + } + return specs(specsPath) +} +const tableHTML = await specs_table(data.rel) +let SpecsComponent +if (tableHTML) { + SpecsComponent = await render(tableHTML) +} else { + SpecsComponent = DefaultComponent +} +--- +
+ +
diff --git a/packages/polymech/src/components/tab-button.astro b/packages/polymech/src/components/tab-button.astro new file mode 100644 index 0000000..36f777f --- /dev/null +++ b/packages/polymech/src/components/tab-button.astro @@ -0,0 +1,33 @@ +--- +import { translate } from "@/base/i18n.js"; +import { I18N_SOURCE_LANGUAGE } from "config/config.js"; +import { slugify } from "@/base/strings.js" + +export interface Props { + title: string; +} +const { title = "title" } = Astro.props; + +const slug = `${slugify(title)}-tab-button`; +const id = `#${slug}-tab`; +const view = `#${slugify(title)}-view`; +const locale = Astro.currentLocale; +const title_i18n = await translate(title, I18N_SOURCE_LANGUAGE, locale); +--- + +
  • + {title_i18n} +
  • diff --git a/packages/polymech/src/components/tab-content.astro b/packages/polymech/src/components/tab-content.astro new file mode 100644 index 0000000..a1bceed --- /dev/null +++ b/packages/polymech/src/components/tab-content.astro @@ -0,0 +1,19 @@ +--- +import { slugify } from "@/base/strings.js"; +export interface Props { + title: string; +} +const { title = "title" } = Astro.props; +const slug = `${slugify(title)}-tab-content`; +const label = `${slug}-tab`; +const view = `${slugify(title)}-view`; +--- + + diff --git a/packages/polymech/src/components/tab-item.astro b/packages/polymech/src/components/tab-item.astro new file mode 100644 index 0000000..e70a856 --- /dev/null +++ b/packages/polymech/src/components/tab-item.astro @@ -0,0 +1,65 @@ +--- +import { translate } from '@/base/i18n.js'; +import { I18N_SOURCE_LANGUAGE } from "config/config.js"; + +// A simple slugify helper (you could import this from a utility file instead) +function slugify(text: string) { + return text + .normalize('NFD') // Normalise accented characters + .replace(/[\u0300-\u036f]/g, '') // Strip accents + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens + .replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens +} + +export interface Props { + title: string; + clazz?: string; + src?: string; // optional +} + +const { + title = 'title', + clazz = 'unstyled', + src, +} = Astro.props; + +// Compute the slug from the original title +const id = slugify(title); + +// Then retrieve the user's locale and translate the title +const locale = Astro.currentLocale; +const title_i18n = await translate(title, I18N_SOURCE_LANGUAGE, locale); +--- + + + +
    + {src ? ( +
    + +
    + ) : ( + + )} +
    + + diff --git a/packages/polymech/src/components/tabs.astro b/packages/polymech/src/components/tabs.astro new file mode 100644 index 0000000..6793a6a --- /dev/null +++ b/packages/polymech/src/components/tabs.astro @@ -0,0 +1,10 @@ +
    +
      + +
    +
    diff --git a/packages/polymech/tsconfig.json b/packages/polymech/tsconfig.json index 8f8a4cb..ed940d7 100644 --- a/packages/polymech/tsconfig.json +++ b/packages/polymech/tsconfig.json @@ -19,7 +19,8 @@ "declaration": true, "paths": { "@/*": ["src/*"], - "config/*": ["src/app/*"] + "config/*": ["src/app/*"], + "components/*": ["src/components/*"] } }, "include": [".astro/types.d.ts", "**/*.ts", "**/*.tsx", "**/*.astro"],