@abhi1693/medusa-nextjs-jsonld 0.0.0-stage → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 abhi1693
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,194 @@
1
- # Temporary Holding Version
1
+ # Medusa Next.js JSON-LD
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/abhi1693/medusa-nextjs-jsonld/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/abhi1693/medusa-nextjs-jsonld/actions/workflows/ci.yml)
4
+ [![Node.js >=20](https://img.shields.io/badge/Node.js-%E2%89%A520-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/)
5
+ [![ES modules](https://img.shields.io/badge/modules-ESM-blue)](https://nodejs.org/api/esm.html)
6
+ [![MIT license](https://img.shields.io/github/license/abhi1693/medusa-nextjs-jsonld)](LICENSE)
7
+ [![Sponsor](https://img.shields.io/badge/Sponsor-abhi1693-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/abhi1693)
8
+
9
+ Server-rendered Schema.org structured data for **Medusa v2** storefronts. ESM TypeScript package with no runtime dependency in the core; optional React 18/19 component for Next.js App Router. Supports Product/Offer, WebSite/Organization, CollectionPage/ItemList, BreadcrumbList and informational WebPage types.
10
+
11
+ Install this library in the **storefront** and add its component to the corresponding server pages. Storefronts own their HTML, routing, pricing context and canonical URLs. The optional Admin preview is a copy-in example for the backend. The library uses no tracking, external service, API key or database migration.
12
+
13
+ [Install](#install) · [Capabilities](#capabilities) · [Product pages](#product-page-minimum-integration) · [Other pages](#coverage-beyond-products) · [Pagination](#reusable-pagination-helpers) · [Development](#development)
14
+
15
+ ## Capabilities
16
+
17
+ | Capability | Behavior |
18
+ | --- | --- |
19
+ | Product and offers | Build Product/Offer data from supplied Medusa v2 calculated prices and explicit inventory state, including valid zero prices. |
20
+ | Site identity | Build WebSite and optional Organization data from configured public facts. |
21
+ | Listing pages | Build CollectionPage/ItemList data for the visible page subset in its actual order. |
22
+ | Navigation and information | Build BreadcrumbList and supported informational WebPage types from caller-supplied routes and content. |
23
+ | Server rendering | Render escaped JSON-LD with the optional React component, including a CSP nonce, without client effects or data fetching. |
24
+ | Pagination | Parse bounded page numbers and build same-origin pagination URLs while preserving other query parameters. |
25
+ | Integration | ESM TypeScript exports for the core and optional React component; optional copy-in Admin preview example. |
26
+
27
+ ## Install
28
+
29
+ The scoped package release version is `0.0.3`. Once that version is available on npm, install it in your storefront:
30
+
31
+ ```sh
32
+ npm install @abhi1693/medusa-nextjs-jsonld@0.0.3
33
+ ```
34
+
35
+ To install a reviewed source checkout, build and pack it first:
36
+
37
+ ```sh
38
+ npm ci
39
+ npm run check
40
+ npm pack
41
+ # In your storefront, install the resulting local tarball:
42
+ npm install /path/to/abhi1693-medusa-nextjs-jsonld-0.0.3.tgz
43
+ ```
44
+
45
+ Requires Node 20+, React 18/19 for the `/react` export. Core imports do not load React or Next. The package is currently ESM-only. Licensed under [MIT](LICENSE).
46
+
47
+ ## Product page (minimum integration)
48
+
49
+ ```tsx
50
+ import { buildProductJsonLd } from "@abhi1693/medusa-nextjs-jsonld"
51
+ import { JsonLd } from "@abhi1693/medusa-nextjs-jsonld/react"
52
+
53
+ // Keep config in your storefront's server code. Use your actual public origin.
54
+ const config = {
55
+ baseUrl: "https://shop.example.com",
56
+ productDefaults: { brand: "Your actual brand" },
57
+ }
58
+
59
+ // After your existing region-aware listProducts call and notFound checks:
60
+ const url = `/${countryCode}/products/${encodeURIComponent(product.handle)}`
61
+ const data = buildProductJsonLd(product, config, { url })
62
+ return <>
63
+ <JsonLd id="product-jsonld" data={data} />
64
+ <ProductTemplate product={product} region={region} countryCode={countryCode} />
65
+ </>
66
+ ```
67
+
68
+ Use your existing page component and props rather than replacing its metadata, variant-image handling or static params. A copyable adapter is in `examples/nextjs/product-page.tsx`. The official starter has promise-valued route params in Next 15, so await `props.params` before building URLs. Keep canonical metadata and JSON-LD URL consistent.
69
+
70
+ Fetch `*variants.calculated_price,+variants.inventory_quantity,+metadata` with the same region/currency and customer context as the visible product. Do not separately fetch anonymous prices if the page shows customer-specific prices. Medusa v2 `calculated_amount` is already in major units: **10 means 10, not 0.10**. Zero-price offers are valid. Currency codes must be three letters; this is format validation, not a currency registry lookup. Supply the actual active ISO currency. Raw price lists and uncalculated prices are not used. Variants without a valid calculated price and currency do not produce offers. Do not use this adapter for Medusa v1's cents-based pricing.
71
+
72
+ Pass `variantUrls` keyed by variant ID only if those URLs really select and render that variant. Without a mapping offers point to the product page; the library does not invent a query parameter or a variant route. A selected variant URL, filtered variants, and visible content must remain consistent. A Product with multiple offers is supported, but this release does not model ProductGroup variant families or promise Google's variant-specific features.
73
+
74
+ ### Configuration and precedence
75
+
76
+ Type declarations in `dist/types.d.ts` are the complete API contract. For supported static product properties, precedence is:
77
+
78
+ 1. `config.productDefaults` (store-wide fallback)
79
+ 2. Product title/description from the supplied Medusa product
80
+ 3. `product.metadata.jsonld` (per-product, explicitly allowlisted)
81
+ 4. `options.overrides` (per-page)
82
+
83
+ Only supply facts that apply to this product and are visible or verifiable. Static fields do not replace calculated prices, availability, canonical identity, or arbitrary JSON-LD. Supported keys: name, description, brand (string or `{name}`), sku, mpn, gtin, gtin8, gtin12, gtin13, gtin14, color, material, size, category, model. These are caller assertions, not verified catalog identifiers. Unknown metadata keys are ignored; raw HTML/script injection is not supported. Do not put secrets in public JSON-LD.
84
+
85
+ Availability comes only from explicit inventory flags/quantity: unmanaged inventory is in stock; managed stock with a positive quantity is in stock; depleted managed stock with backorders enabled is backorder; depleted managed stock with backorders disabled is out of stock. Incomplete inventory data is omitted instead of guessed. No ratings, reviews, GTINs, shipping policies or return policies are invented. Missing required data for a particular Google rich result remains a validation warning to resolve with truthful data, not a fabricated default.
86
+
87
+ ## Coverage beyond products
88
+
89
+ ```tsx
90
+ import {
91
+ buildHomeJsonLd, buildListingJsonLd, buildBreadcrumbJsonLd, buildPageJsonLd,
92
+ } from "@abhi1693/medusa-nextjs-jsonld"
93
+
94
+ // Put on the actual localized homepage, not every layout or checkout page.
95
+ const home = buildHomeJsonLd({
96
+ baseUrl: "https://shop.example.com",
97
+ organization: { name: "Your legal/store name", url: "https://shop.example.com" },
98
+ }, { url: "/us", name: "Your storefront" })
99
+
100
+ // Collections, categories, and store/catalog pages use their REAL visible items.
101
+ const listing = buildListingJsonLd({
102
+ name: "Summer collection",
103
+ products: visibleProducts.map((p) => ({
104
+ name: p.title,
105
+ url: `/us/products/${encodeURIComponent(p.handle)}`,
106
+ })),
107
+ }, { baseUrl: "https://shop.example.com" }, { url: "/us/collections/summer" })
108
+
109
+ const crumbs = buildBreadcrumbJsonLd([
110
+ { name: "Home", url: "https://shop.example.com/us" },
111
+ { name: "Summer", url: "https://shop.example.com/us/collections/summer" },
112
+ ])
113
+
114
+ const about = buildPageJsonLd({
115
+ name: "About us", url: "/us/about", type: "AboutPage",
116
+ }, { baseUrl: "https://shop.example.com" })
117
+ ```
118
+
119
+ Render each with `<JsonLd data={...} />`. Use `CollectionPage` for collection/category pages without claiming a Google product carousel. ItemList describes only products actually shown on that page, in display order. Paginated and filtered pages should use their actual canonical URL and visible page subset, not every product in the collection. Do not guess nested category paths from one handle. The official starter's category route is `[...category]`; construct it from the resolved ancestor path. Breadcrumbs must match the navigable hierarchy. `buildPageJsonLd` also supports other explicitly typed informational pages according to its type declaration.
120
+
121
+ Do not add Product markup to cart, checkout, account, or order-confirmation pages. Avoid duplicating schemas from other SEO plugins. This package deliberately does not invent SearchAction endpoints, FAQ answers, articles, authors, product review aggregates or business addresses.
122
+
123
+ ## Admin preview
124
+
125
+ See [the optional product details widget](examples/medusa-admin/README.md). It uses the same builder but is **a preview**, not proof of the regional storefront output. Admin products do not normally contain storefront calculated prices. The widget does not scrape your storefront, persist settings, or transmit catalog data to another service. Configuration remains explicit in your storefront code; the copy-in widget is not auto-discovered by installing the storefront package.
126
+
127
+ ## Security, rendering and validation
128
+
129
+ `JsonLd` is server-component compatible and renders an ordinary `application/ld+json` script, without effects or client-only hydration. It accepts a CSP `nonce` and optional `id`. `stringifyJsonLd` escapes HTML-significant characters and Unicode line separators, preventing product text such as `</script>` from breaking out of the script. Use this serializer rather than raw JSON.stringify with dangerouslySetInnerHTML. Invalid mandatory canonical URLs throw; supply a trusted configured base URL rather than request headers. Malformed optional product image URLs are omitted so bad catalog assets do not break rendering. Invalid canonical, variant, organization, listing and breadcrumb URLs throw; validate configuration before deployment. URL validation does not prove same-origin, public reachability or canonical correctness: callers own those choices, including any fragment or query string. Don't fetch arbitrary storefront URLs from an admin/backend endpoint.
130
+
131
+ ## Development
132
+
133
+ ```sh
134
+ npm ci
135
+ npm run check
136
+ ```
137
+
138
+ Tests cover builders, unsafe strings, missing prices, stock behavior, and the rendered React script. CI runs Node 20/22/24. Before production:
139
+
140
+ 1. Render a real product server page in each region/currency, including zero price, out-of-stock, unknown inventory and variant states. Inspect **View Source** or the actual HTML response for `application/ld+json`; an admin preview is not this test.
141
+ 2. Check offers against the visible price, currency, stock and canonical URL. Test cache invalidation after product/price/inventory edits. This package introduces no cache or webhook; use your storefront's existing Medusa revalidation.
142
+ 3. Test the page with Google's [Rich Results Test](https://search.google.com/test/rich-results) and [Schema.org validator](https://validator.schema.org/). They serve different purposes; valid Schema.org data does not guarantee a Google rich result.
143
+ 4. Verify production crawler access, canonical/noindex policy, script presence with CSP, and any existing SEO plugin duplication. Monitor Search Console after deployment.
144
+
145
+ No live store credentials or deployment are needed for unit tests. Local tests are not claims that Google crawled a store or that a rich result will appear.
146
+
147
+ ## Integration basis
148
+
149
+ The implementation was based on the official [Medusa Next.js starter](https://github.com/medusajs/nextjs-starter-medusa/tree/9818886f06e493cb2249733d114d339aa216ef00): product server page, region-aware product fetch, money formatting and inventory UI. That snapshot uses Next 15.3.9, React 19, and Medusa v2. Other Next App Router versions may work, but have not been exercised in a full deployed storefront here. Follow [Next.js JSON-LD guidance](https://nextjs.org/docs/app/guides/json-ld) and [Google's Product guidance](https://developers.google.com/search/docs/appearance/structured-data/product).
150
+
151
+ ## Reusable pagination helpers
152
+
153
+ Both helpers and their option interfaces are exported from the package root. They
154
+ have no Medusa, Next.js, or React dependency and do not invent merchant routes or
155
+ filter parameters.
156
+
157
+ ```ts
158
+ import { parsePageNumber, buildPaginatedUrl } from '@abhi1693/medusa-nextjs-jsonld'
159
+
160
+ const config = { baseUrl: 'https://shop.example.com' } // trusted server configuration
161
+ const page = parsePageNumber(searchParams.page, { max: 10000, fallback: 1 })
162
+ const url = buildPaginatedUrl('/catalog?color=blue&sort=new', page, config)
163
+ // Page 2: https://shop.example.com/catalog?color=blue&sort=new&page=2
164
+ // Page 1: https://shop.example.com/catalog?color=blue&sort=new
165
+ const customUrl = buildPaginatedUrl('/catalog?color=blue', page, config, {
166
+ pageParameter: 'p',
167
+ })
168
+ ```
169
+
170
+ - `parsePageNumber(input: unknown, options?: ParsePageNumberOptions): number`
171
+ accepts decimal digit strings representing positive safe integers (leading
172
+ zeros are accepted). Missing values, arrays, numbers, whitespace, signs,
173
+ fractions, exponent notation, unsafe integers, and values exceeding `max`
174
+ return `fallback`. Defaults are `max: Number.MAX_SAFE_INTEGER` and
175
+ `fallback: 1`. Both options must be positive safe integers and fallback must
176
+ not exceed max; invalid options throw `RangeError`.
177
+ - `buildPaginatedUrl(path: string, page: number, config: Pick<JsonLdConfig, 'baseUrl'>,
178
+ options?: PaginatedUrlOptions): string` resolves relative paths using standard
179
+ URL semantics against the configured base URL. The default `pageParameter`
180
+ is `page`; other query parameters, repeated filter values and fragments are
181
+ retained. All instances of the selected page parameter are removed on page 1,
182
+ or replaced by one value otherwise. URLSearchParams may normalize query
183
+ encoding while preserving its decoded values. Pass actual route/filter
184
+ parameters from the page, not fabricated facets.
185
+ - The URL helper requires a positive safe integer page, a nonempty query key
186
+ without whitespace/control characters, and an absolute HTTP(S) base URL
187
+ without credentials. Cross-origin URLs and credential-bearing URLs are
188
+ rejected. Configure baseUrl on the server, never from request headers.
189
+ These helpers do not impose an SEO filter/canonical policy: callers decide
190
+ which real filters belong in canonical metadata and JSON-LD.
191
+
192
+ ## License
193
+
194
+ [MIT](LICENSE) © 2026 abhi1693.
package/dist/core.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { BreadcrumbItem, HomeJsonLdOptions, JsonLdConfig, JsonLdDocument, JsonValue, ListingInput, ListingJsonLdOptions, MedusaProduct, PageInput, ProductJsonLdOptions } from './types.js';
2
+ /** Build Product from a priced Medusa v2 response. Unknown prices and stock are omitted. */
3
+ export declare function buildProductJsonLd(product: MedusaProduct, config: JsonLdConfig, options: ProductJsonLdOptions): JsonLdDocument;
4
+ export declare function buildHomeJsonLd(config: JsonLdConfig, options?: HomeJsonLdOptions): JsonLdDocument;
5
+ export declare function buildBreadcrumbJsonLd(items: BreadcrumbItem[]): JsonLdDocument;
6
+ export declare function buildListingJsonLd(listing: ListingInput, config: JsonLdConfig, options: ListingJsonLdOptions): JsonLdDocument;
7
+ export declare function buildPageJsonLd(page: PageInput, config: JsonLdConfig): JsonLdDocument;
8
+ /** Escape HTML-significant characters to make JSON safe inside an inline script element. */
9
+ export declare function stringifyJsonLd(value: JsonValue): string;
package/dist/core.js ADDED
@@ -0,0 +1,209 @@
1
+ const CONTEXT = 'https://schema.org';
2
+ const PRODUCT_TEXT_FIELDS = [
3
+ 'name', 'description', 'sku', 'mpn', 'gtin', 'gtin8', 'gtin12', 'gtin13',
4
+ 'gtin14', 'color', 'material', 'size', 'category', 'model',
5
+ ];
6
+ function nonempty(value) {
7
+ return typeof value === 'string' && value.trim().length > 0;
8
+ }
9
+ /** Only HTTP(S) URLs without embedded credentials may reach the output. */
10
+ function absoluteUrl(value, base) {
11
+ if (!nonempty(value))
12
+ throw new TypeError('A nonempty HTTP(S) URL is required.');
13
+ let parsed;
14
+ try {
15
+ parsed = base ? new URL(value, base) : new URL(value);
16
+ }
17
+ catch {
18
+ throw new TypeError(`Invalid URL: ${value}`);
19
+ }
20
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) {
21
+ throw new TypeError('URLs must use HTTP(S) and must not contain credentials.');
22
+ }
23
+ return parsed.href;
24
+ }
25
+ function baseUrl(config) {
26
+ return absoluteUrl(config.baseUrl);
27
+ }
28
+ function idFor(url, fragment) {
29
+ const result = new URL(url);
30
+ result.hash = fragment;
31
+ return result.href;
32
+ }
33
+ function text(target, key, value) {
34
+ if (nonempty(value))
35
+ target[key] = value;
36
+ }
37
+ function productFields(input) {
38
+ if (!input || typeof input !== 'object' || Array.isArray(input))
39
+ return {};
40
+ const source = input;
41
+ const fields = {};
42
+ for (const key of PRODUCT_TEXT_FIELDS)
43
+ text(fields, key, source[key]);
44
+ const brand = source.brand;
45
+ if (nonempty(brand))
46
+ fields.brand = { '@type': 'Brand', name: brand };
47
+ else if (brand && typeof brand === 'object' && !Array.isArray(brand)) {
48
+ const name = brand.name;
49
+ if (nonempty(name))
50
+ fields.brand = { '@type': 'Brand', name };
51
+ }
52
+ return fields;
53
+ }
54
+ function availability(variant) {
55
+ if (variant.manage_inventory === false)
56
+ return `${CONTEXT}/InStock`;
57
+ if (variant.manage_inventory !== true)
58
+ return undefined;
59
+ const count = variant.inventory_quantity;
60
+ if (typeof count !== 'number' || !Number.isFinite(count))
61
+ return undefined;
62
+ if (count > 0)
63
+ return `${CONTEXT}/InStock`;
64
+ if (variant.allow_backorder === true)
65
+ return `${CONTEXT}/BackOrder`;
66
+ if (variant.allow_backorder === false)
67
+ return `${CONTEXT}/OutOfStock`;
68
+ return undefined;
69
+ }
70
+ /** Build Product from a priced Medusa v2 response. Unknown prices and stock are omitted. */
71
+ export function buildProductJsonLd(product, config, options) {
72
+ const base = baseUrl(config);
73
+ const url = absoluteUrl(options.url, base);
74
+ const result = {
75
+ '@context': CONTEXT, '@type': 'Product', '@id': idFor(url, 'product'), url,
76
+ };
77
+ Object.assign(result, productFields(config.productDefaults));
78
+ text(result, 'name', product.title);
79
+ text(result, 'description', product.description);
80
+ Object.assign(result, productFields(product.metadata?.jsonld), productFields(options.overrides));
81
+ // Catalog assets are optional: a malformed image must not break the page.
82
+ const images = [product.thumbnail, ...(product.images ?? []).map((image) => image.url)]
83
+ .filter(nonempty).flatMap((image) => {
84
+ try {
85
+ return [absoluteUrl(image, base)];
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ });
91
+ if (images.length)
92
+ result.image = [...new Set(images)];
93
+ const offers = [];
94
+ for (const variant of product.variants ?? []) {
95
+ const price = variant.calculated_price?.calculated_amount;
96
+ const currency = variant.calculated_price?.currency_code;
97
+ if (typeof price !== 'number' || !Number.isFinite(price) || price < 0 ||
98
+ typeof currency !== 'string' || !/^[a-z]{3}$/i.test(currency))
99
+ continue;
100
+ const suppliedUrl = variant.id && options.variantUrls &&
101
+ Object.prototype.hasOwnProperty.call(options.variantUrls, variant.id)
102
+ ? options.variantUrls[variant.id] : undefined;
103
+ const offer = {
104
+ '@type': 'Offer', url: suppliedUrl === undefined ? url : absoluteUrl(suppliedUrl, base),
105
+ price, priceCurrency: currency.toUpperCase(),
106
+ };
107
+ text(offer, 'sku', variant.sku);
108
+ text(offer, 'name', variant.title);
109
+ const stock = availability(variant);
110
+ if (stock)
111
+ offer.availability = stock;
112
+ offers.push(offer);
113
+ }
114
+ if (offers.length)
115
+ result.offers = offers;
116
+ if (options.breadcrumbs?.length) {
117
+ return { '@context': CONTEXT, '@graph': [withoutContext(result), withoutContext(buildBreadcrumbJsonLd(options.breadcrumbs))] };
118
+ }
119
+ return result;
120
+ }
121
+ function withoutContext(document) {
122
+ const { '@context': _context, ...node } = document;
123
+ return node;
124
+ }
125
+ export function buildHomeJsonLd(config, options = {}) {
126
+ const base = baseUrl(config);
127
+ const url = absoluteUrl(options.url ?? base, base);
128
+ const website = { '@type': 'WebSite', '@id': idFor(url, 'website'), url };
129
+ text(website, 'name', options.name ?? config.siteName);
130
+ text(website, 'description', options.description);
131
+ text(website, 'inLanguage', options.inLanguage);
132
+ if (!config.organization)
133
+ return { '@context': CONTEXT, ...website };
134
+ const organizationUrl = absoluteUrl(config.organization.url ?? base, base);
135
+ if (!nonempty(config.organization.name))
136
+ throw new TypeError('Organization name is required.');
137
+ const organization = {
138
+ '@type': 'Organization', '@id': idFor(organizationUrl, 'organization'),
139
+ name: config.organization.name, url: organizationUrl,
140
+ };
141
+ if (config.organization.logo)
142
+ organization.logo = absoluteUrl(config.organization.logo, base);
143
+ if (config.organization.sameAs?.length) {
144
+ organization.sameAs = config.organization.sameAs.map((link) => absoluteUrl(link));
145
+ }
146
+ website.publisher = { '@id': organization['@id'] };
147
+ return { '@context': CONTEXT, '@graph': [website, organization] };
148
+ }
149
+ export function buildBreadcrumbJsonLd(items) {
150
+ return {
151
+ '@context': CONTEXT, '@type': 'BreadcrumbList',
152
+ itemListElement: items.map((item, index) => {
153
+ if (!nonempty(item.name))
154
+ throw new TypeError('Breadcrumb name is required.');
155
+ return { '@type': 'ListItem', position: index + 1, name: item.name, item: absoluteUrl(item.url) };
156
+ }),
157
+ };
158
+ }
159
+ export function buildListingJsonLd(listing, config, options) {
160
+ const base = baseUrl(config);
161
+ const url = absoluteUrl(options.url, base);
162
+ if (!nonempty(listing.name))
163
+ throw new TypeError('Listing name is required.');
164
+ const result = {
165
+ '@context': CONTEXT, '@type': 'CollectionPage', '@id': idFor(url, 'webpage'),
166
+ url, name: listing.name,
167
+ mainEntity: {
168
+ '@type': 'ItemList', numberOfItems: listing.products.length,
169
+ itemListElement: listing.products.map((product, index) => {
170
+ const item = { '@type': 'ListItem', position: index + 1, url: absoluteUrl(product.url, base) };
171
+ text(item, 'name', product.name);
172
+ return item;
173
+ }),
174
+ },
175
+ };
176
+ text(result, 'description', listing.description);
177
+ text(result, 'inLanguage', options.inLanguage);
178
+ const breadcrumbs = options.breadcrumbs ?? listing.breadcrumbs;
179
+ if (breadcrumbs?.length)
180
+ result.breadcrumb = withoutContext(buildBreadcrumbJsonLd(breadcrumbs));
181
+ return result;
182
+ }
183
+ export function buildPageJsonLd(page, config) {
184
+ const base = baseUrl(config);
185
+ const url = absoluteUrl(page.url, base);
186
+ if (!nonempty(page.name))
187
+ throw new TypeError('Page name is required.');
188
+ const type = page.type ?? 'WebPage';
189
+ if (!['WebPage', 'AboutPage', 'ContactPage'].includes(type))
190
+ throw new TypeError('Unsupported page type.');
191
+ const result = {
192
+ '@context': CONTEXT, '@type': type, '@id': idFor(url, 'webpage'), url, name: page.name,
193
+ };
194
+ text(result, 'description', page.description);
195
+ text(result, 'inLanguage', page.inLanguage);
196
+ if (page.breadcrumbs?.length)
197
+ result.breadcrumb = withoutContext(buildBreadcrumbJsonLd(page.breadcrumbs));
198
+ return result;
199
+ }
200
+ /** Escape HTML-significant characters to make JSON safe inside an inline script element. */
201
+ export function stringifyJsonLd(value) {
202
+ const serialized = JSON.stringify(value);
203
+ if (serialized === undefined)
204
+ throw new TypeError('A JSON value is required.');
205
+ const escapes = {
206
+ '<': '\\u003c', '>': '\\u003e', '&': '\\u0026', '\u2028': '\\u2028', '\u2029': '\\u2029',
207
+ };
208
+ return serialized.replace(/[<>&\u2028\u2029]/g, (character) => escapes[character]);
209
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export * from "./core.js";
3
+ export * from "./pagination.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export * from "./core.js";
3
+ export * from "./pagination.js";
@@ -0,0 +1,18 @@
1
+ import type { JsonLdConfig } from './types.js';
2
+ export interface ParsePageNumberOptions {
3
+ /** Largest accepted page (inclusive). Defaults to Number.MAX_SAFE_INTEGER. */
4
+ max?: number;
5
+ /** Returned for invalid input. Defaults to 1 and must be within [1, max]. */
6
+ fallback?: number;
7
+ }
8
+ export interface PaginatedUrlOptions {
9
+ /** Query parameter to replace or remove. Defaults to "page". */
10
+ pageParameter?: string;
11
+ }
12
+ /** Parse a decimal digit string. Invalid input, including arrays, uses fallback. */
13
+ export declare function parsePageNumber(input: unknown, options?: ParsePageNumberOptions): number;
14
+ /**
15
+ * Resolve a path against a trusted configured base URL, preserving other query
16
+ * parameters and fragments. Absolute URLs must have the configured origin.
17
+ */
18
+ export declare function buildPaginatedUrl(path: string, page: number, config: Pick<JsonLdConfig, 'baseUrl'>, options?: PaginatedUrlOptions): string;
@@ -0,0 +1,51 @@
1
+ function positiveInteger(value) {
2
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
3
+ }
4
+ /** Parse a decimal digit string. Invalid input, including arrays, uses fallback. */
5
+ export function parsePageNumber(input, options = {}) {
6
+ const max = options.max ?? Number.MAX_SAFE_INTEGER;
7
+ const fallback = options.fallback ?? 1;
8
+ if (!positiveInteger(max) || !positiveInteger(fallback) || fallback > max) {
9
+ throw new RangeError('max and fallback must be positive safe integers, with fallback <= max.');
10
+ }
11
+ if (typeof input !== 'string' || !/^[0-9]+$/.test(input))
12
+ return fallback;
13
+ const page = Number(input);
14
+ return positiveInteger(page) && page <= max ? page : fallback;
15
+ }
16
+ /**
17
+ * Resolve a path against a trusted configured base URL, preserving other query
18
+ * parameters and fragments. Absolute URLs must have the configured origin.
19
+ */
20
+ export function buildPaginatedUrl(path, page, config, options = {}) {
21
+ if (!positiveInteger(page))
22
+ throw new RangeError('page must be a positive safe integer.');
23
+ const parameter = options.pageParameter ?? 'page';
24
+ if (typeof parameter !== 'string' || !parameter.trim() || /[\s\u0000-\u001f\u007f]/.test(parameter)) {
25
+ throw new TypeError('pageParameter must be a nonempty query key without whitespace or control characters.');
26
+ }
27
+ if (typeof config?.baseUrl !== 'string' || !config.baseUrl.trim()) {
28
+ throw new TypeError('A trusted absolute HTTP(S) baseUrl is required.');
29
+ }
30
+ let base;
31
+ let url;
32
+ try {
33
+ base = new URL(config.baseUrl);
34
+ if (typeof path !== 'string' || !path.trim())
35
+ throw new TypeError();
36
+ url = new URL(path, base);
37
+ }
38
+ catch {
39
+ throw new TypeError('A valid path and absolute HTTP(S) baseUrl are required.');
40
+ }
41
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password ||
42
+ url.origin !== base.origin || url.username || url.password ||
43
+ !['http:', 'https:'].includes(url.protocol)) {
44
+ throw new TypeError('Pagination URLs must use the configured HTTP(S) origin without credentials.');
45
+ }
46
+ if (page === 1)
47
+ url.searchParams.delete(parameter);
48
+ else
49
+ url.searchParams.set(parameter, String(page));
50
+ return url.href;
51
+ }
@@ -0,0 +1,14 @@
1
+ import { stringifyJsonLd } from "./core.js";
2
+ /** Render in a server page, next to the visible content. No client effect or fetch. */
3
+ export declare function JsonLd({ data, id, nonce }: {
4
+ data: Parameters<typeof stringifyJsonLd>[0] | null | undefined;
5
+ id?: string;
6
+ nonce?: string;
7
+ }): import("react").DetailedReactHTMLElement<{
8
+ type: string;
9
+ id: string | undefined;
10
+ nonce: string | undefined;
11
+ dangerouslySetInnerHTML: {
12
+ __html: string;
13
+ };
14
+ }, HTMLElement> | null;
package/dist/react.js ADDED
@@ -0,0 +1,13 @@
1
+ import { createElement } from "react";
2
+ import { stringifyJsonLd } from "./core.js";
3
+ /** Render in a server page, next to the visible content. No client effect or fetch. */
4
+ export function JsonLd({ data, id, nonce }) {
5
+ if (data == null)
6
+ return null;
7
+ return createElement("script", {
8
+ type: "application/ld+json",
9
+ id,
10
+ nonce,
11
+ dangerouslySetInnerHTML: { __html: stringifyJsonLd(data) },
12
+ });
13
+ }
@@ -0,0 +1,109 @@
1
+ /** Values that can be safely represented in JSON. */
2
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export interface JsonLdObject {
6
+ [key: string]: JsonValue;
7
+ }
8
+ export type JsonLdDocument = JsonLdObject;
9
+ /** A structural subset of Medusa v2 StoreProduct; no Medusa runtime dependency. */
10
+ export interface MedusaProductVariant {
11
+ id?: string;
12
+ title?: string | null;
13
+ sku?: string | null;
14
+ barcode?: string | null;
15
+ manage_inventory?: boolean | null;
16
+ allow_backorder?: boolean | null;
17
+ inventory_quantity?: number | null;
18
+ calculated_price?: {
19
+ /** Medusa v2 calculated amounts are major currency units. Do not divide by 100. */
20
+ calculated_amount?: number | null;
21
+ currency_code?: string | null;
22
+ } | null;
23
+ }
24
+ export interface MedusaProduct {
25
+ id?: string;
26
+ handle?: string | null;
27
+ title?: string | null;
28
+ description?: string | null;
29
+ thumbnail?: string | null;
30
+ images?: Array<{
31
+ url?: string | null;
32
+ }> | null;
33
+ variants?: MedusaProductVariant[] | null;
34
+ metadata?: Record<string, unknown> | null;
35
+ }
36
+ /** Deliberately excludes ratings, reviews, offers, availability, and JSON-LD control keys. */
37
+ export interface ProductFields {
38
+ name?: string;
39
+ description?: string;
40
+ brand?: string | {
41
+ name: string;
42
+ };
43
+ sku?: string;
44
+ mpn?: string;
45
+ gtin?: string;
46
+ gtin8?: string;
47
+ gtin12?: string;
48
+ gtin13?: string;
49
+ gtin14?: string;
50
+ color?: string;
51
+ material?: string;
52
+ size?: string;
53
+ category?: string;
54
+ model?: string;
55
+ }
56
+ export interface OrganizationConfig {
57
+ name: string;
58
+ url?: string;
59
+ logo?: string;
60
+ sameAs?: string[];
61
+ }
62
+ export interface JsonLdConfig {
63
+ /** Absolute HTTP(S) public store root. Never derived from request headers. */
64
+ baseUrl: string;
65
+ siteName?: string;
66
+ organization?: OrganizationConfig;
67
+ productDefaults?: ProductFields;
68
+ }
69
+ export interface BreadcrumbItem {
70
+ name: string; /** Absolute HTTP(S) URL. */
71
+ url: string;
72
+ }
73
+ export interface ProductJsonLdOptions {
74
+ /** Actual canonical product URL, including locale when applicable. Relative URLs resolve against baseUrl. */
75
+ url: string;
76
+ /** Actual URLs for individual variants, keyed by variant ID. No variant routes are invented. */
77
+ variantUrls?: Record<string, string>;
78
+ overrides?: ProductFields;
79
+ breadcrumbs?: BreadcrumbItem[];
80
+ }
81
+ export interface HomeJsonLdOptions {
82
+ url?: string;
83
+ name?: string;
84
+ description?: string;
85
+ inLanguage?: string;
86
+ }
87
+ export interface ListingProduct {
88
+ name?: string; /** Actual visible product destination. */
89
+ url: string;
90
+ }
91
+ export interface ListingInput {
92
+ name: string;
93
+ description?: string;
94
+ products: ListingProduct[];
95
+ breadcrumbs?: BreadcrumbItem[];
96
+ }
97
+ export interface ListingJsonLdOptions {
98
+ url: string;
99
+ inLanguage?: string;
100
+ breadcrumbs?: BreadcrumbItem[];
101
+ }
102
+ export interface PageInput {
103
+ name: string;
104
+ url: string;
105
+ description?: string;
106
+ type?: 'WebPage' | 'AboutPage' | 'ContactPage';
107
+ inLanguage?: string;
108
+ breadcrumbs?: BreadcrumbItem[];
109
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ # Optional Medusa Admin preview
2
+
3
+ This is a **copy-in Medusa v2 Admin widget**, not an installable backend plugin.
4
+ The storefront JSON-LD package does not need this widget to work. Installing it
5
+ only provides a local, read-only Product preview on the Admin product details
6
+ page. It does not inject scripts into a Next.js storefront.
7
+
8
+ ## Install
9
+
10
+ 1. Build/package this repository with `npm pack`. Install the resulting
11
+ `abhi1693-medusa-nextjs-jsonld-0.0.2.tgz` in your **Medusa application** (in addition to
12
+ installing it in the separate Next.js storefront), for example:
13
+
14
+ ```sh
15
+ npm install /absolute/path/to/abhi1693-medusa-nextjs-jsonld-0.0.2.tgz
16
+ ```
17
+
18
+ Use the tarball filename actually generated by your reviewed source checkout
19
+ if the version changes.
20
+
21
+ 2. Copy `widgets/product-jsonld.tsx` to
22
+ `src/admin/widgets/product-jsonld.tsx` in your Medusa application.
23
+ 3. The example uses `product.details`, the current injection zone for Medusa
24
+ **2.17.2+**. For older Medusa v2 installations, replace that zone with
25
+ `product.details.after`. Follow your installed Medusa release's documentation.
26
+ 4. Restart the application/Admin dev server, or rebuild your deployed Admin.
27
+ Open a product details page and enter the public storefront base URL and
28
+ actual canonical product path. Correct the suggested `/products/{handle}`
29
+ path if your storefront uses country/locale prefixes or a different router.
30
+
31
+ The host Medusa application supplies React, `@medusajs/admin-sdk`,
32
+ `@medusajs/framework`, and `@medusajs/ui`. Keep those packages aligned with your
33
+ Medusa installation rather than independently upgrading them for this widget.
34
+
35
+ ## What the preview means
36
+
37
+ - The widget calls the **same `buildProductJsonLd` implementation** as the
38
+ storefront. It passes the current Admin product's title, description,
39
+ thumbnail, images, and metadata, plus the URLs entered locally.
40
+ - Admin variants are deliberately omitted. An Admin product record does not
41
+ establish the visitor's region, current calculated prices, tax context, or
42
+ sellable inventory context. Therefore this preview has **no offers** and is
43
+ **not a complete product-rich-result eligibility test**.
44
+ - Saved `metadata.jsonld` is shown separately as read-only source data. Only
45
+ supported, validated fields are used by the builder. Arbitrary raw JSON-LD,
46
+ ratings, reviews, offers, availability, and JSON-LD control keys are not
47
+ accepted as overrides. Consult the package README for the supported fields.
48
+ - This example passes only `baseUrl` as global config. To match a real store,
49
+ pass your public `productDefaults` too. Do not import a server config module
50
+ that contains secrets into the Admin browser bundle. Storefront per-page
51
+ overrides, breadcrumbs, and region-specific prices can change the final
52
+ output; inspect the actual storefront page for the authoritative result.
53
+ - The suggested URL is a convenience, not a claim that the product is published
54
+ or that the route exists. Nothing checks availability at that address.
55
+
56
+ ## Safety and scope
57
+
58
+ URLs and preview state stay in the current browser component. They are reset
59
+ when switching products or reloading. There is no network fetch, metadata write,
60
+ backend endpoint, persistent setting, or server-side URL fetch in this example.
61
+ React renders preview JSON as text in a `pre` element; it is never inserted as
62
+ HTML or as an executable script. The widget is not a metadata editor.
63
+
64
+ If you change saved product metadata through your existing Medusa workflow,
65
+ refresh the details page if its data has not updated. Adding a dedicated editor
66
+ would require normal authenticated Admin API writes and explicit handling of
67
+ validation, concurrent changes, and permissions; this example does not implement
68
+ that API or imply that saving metadata alone installs storefront rendering.
69
+
70
+ ## Validation and references
71
+
72
+ The widget was strict-TypeScript-checked against actual installed packages:
73
+ `@medusajs/admin-sdk@2.22.4`, `@medusajs/framework@2.22.4`, React 18 types, and
74
+ `@medusajs/ui@4.2.11`, using this repository's builder types. No running Medusa instance
75
+ or browser end-to-end integration was available, so test placement and styling
76
+ in your own application before deploying.
77
+
78
+ Official sources checked:
79
+
80
+ - [Admin widgets, props, and placement](https://docs.medusajs.com/learn/fundamentals/admin/widgets)
81
+ - [Medusa Admin customization](https://docs.medusajs.com/learn/fundamentals/admin)
82
+
83
+ The widget guide documents `defineWidgetConfig`, arrow-function default widget
84
+ components, `DetailWidgetProps<AdminProduct>`, and the 2.17.2 placement change.
85
+
86
+ Manual acceptance checks:
87
+
88
+ 1. Product details shows the widget and the saved `metadata.jsonld` value.
89
+ 2. Valid public base/product URLs produce a Product preview without offers.
90
+ 3. Unsupported protocols and URLs with embedded credentials show a message.
91
+ 4. Changing URL inputs changes only the preview; it sends no requests and does
92
+ not change product metadata.
93
+ 5. Navigating to another product resets the local URL inputs.
94
+ 6. Validate the deployed storefront page using Google's Rich Results Test and
95
+ Schema.org's validator. Their results apply to the real page, not this Admin
96
+ preview, and valid markup does not guarantee a search enhancement.
@@ -0,0 +1,109 @@
1
+ import { defineWidgetConfig } from "@medusajs/admin-sdk"
2
+ import type { AdminProduct, DetailWidgetProps } from "@medusajs/framework/types"
3
+ import { Container, Heading } from "@medusajs/ui"
4
+ import { useId, useMemo, useState } from "react"
5
+ import { buildProductJsonLd } from "@abhi1693/medusa-nextjs-jsonld"
6
+
7
+ // Copy into src/admin/widgets in your Medusa application. See ../README.md.
8
+ // This is a local preview, not a metadata editor or storefront injector.
9
+ const ProductJsonLdPreview = ({ product }: { product: AdminProduct }) => {
10
+ const id = useId()
11
+ const [baseUrl, setBaseUrl] = useState("")
12
+ const [productPath, setProductPath] = useState(
13
+ `/products/${encodeURIComponent(product.handle || product.id)}`
14
+ )
15
+ const preview = useMemo(() => {
16
+ if (!baseUrl.trim()) return { message: "Enter your storefront base URL to preview." }
17
+ try {
18
+ const base = new URL(baseUrl.trim())
19
+ if (!["https:", "http:"].includes(base.protocol) || base.username || base.password) {
20
+ throw new Error("Use an HTTP(S) storefront URL without credentials.")
21
+ }
22
+ if (!productPath.trim()) throw new Error("Enter the canonical product URL or path.")
23
+ const url = new URL(productPath.trim(), base)
24
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) {
25
+ throw new Error("Use an HTTP(S) product URL without credentials.")
26
+ }
27
+ // Deliberately omit admin variants. They do not establish the active
28
+ // storefront region, calculated prices, or sellable inventory context.
29
+ const value = buildProductJsonLd(
30
+ {
31
+ id: product.id,
32
+ title: product.title,
33
+ description: product.description,
34
+ thumbnail: product.thumbnail,
35
+ images: product.images,
36
+ metadata: product.metadata,
37
+ },
38
+ { baseUrl: base.href },
39
+ { url: url.href }
40
+ )
41
+ return { json: JSON.stringify(value, null, 2) }
42
+ } catch (error) {
43
+ return { message: error instanceof Error ? error.message : "Unable to build this preview." }
44
+ }
45
+ }, [baseUrl, productPath, product])
46
+
47
+ return (
48
+ <Container className="flex flex-col gap-4">
49
+ <Heading level="h2">Product structured data preview</Heading>
50
+ <p className="text-ui-fg-subtle text-sm">
51
+ Local preview using product details and supported metadata.jsonld overrides.
52
+ Offers are omitted: this admin record has no active storefront region or
53
+ calculated prices. These inputs are not saved and no URLs are fetched.
54
+ </p>
55
+ <label className="flex flex-col gap-1 text-sm" htmlFor={`${id}-base`}>
56
+ Storefront base URL
57
+ <input
58
+ id={`${id}-base`}
59
+ type="url"
60
+ placeholder="https://store.example.com"
61
+ value={baseUrl}
62
+ onChange={(event) => setBaseUrl(event.target.value)}
63
+ className="rounded border p-2"
64
+ autoComplete="off"
65
+ spellCheck={false}
66
+ />
67
+ </label>
68
+ <label className="flex flex-col gap-1 text-sm" htmlFor={`${id}-product`}>
69
+ Canonical product URL or path (include your country or locale prefix)
70
+ <input
71
+ id={`${id}-product`}
72
+ type="text"
73
+ value={productPath}
74
+ onChange={(event) => setProductPath(event.target.value)}
75
+ className="rounded border p-2"
76
+ autoComplete="off"
77
+ spellCheck={false}
78
+ />
79
+ </label>
80
+ {preview.message ? <p role="status" className="text-sm">{preview.message}</p> : null}
81
+ {preview.json ? (
82
+ <pre aria-label="Product JSON-LD preview" className="max-h-96 overflow-auto rounded bg-ui-bg-subtle p-3 text-xs">
83
+ {preview.json}
84
+ </pre>
85
+ ) : null}
86
+ <details>
87
+ <summary className="cursor-pointer text-sm">Saved metadata.jsonld (read-only)</summary>
88
+ <pre className="max-h-64 overflow-auto rounded bg-ui-bg-subtle p-3 text-xs">
89
+ {JSON.stringify(product.metadata?.jsonld ?? null, null, 2)}
90
+ </pre>
91
+ </details>
92
+ <p className="text-ui-fg-subtle text-sm">
93
+ This is not a Google eligibility check. Validate the actual public product
94
+ page after installing the storefront component. Storefront config defaults
95
+ and explicit overrides can also change the final output.
96
+ </p>
97
+ </Container>
98
+ )
99
+ }
100
+
101
+ // Key by product ID so navigating between products resets the local URL inputs.
102
+ const ProductJsonLdWidget = ({ data }: DetailWidgetProps<AdminProduct>) => (
103
+ <ProductJsonLdPreview key={data.id} product={data} />
104
+ )
105
+
106
+ // Medusa 2.17.2+. For older v2 releases, use "product.details.after".
107
+ export const config = defineWidgetConfig({ zone: "product.details" })
108
+
109
+ export default ProductJsonLdWidget
@@ -0,0 +1,20 @@
1
+ // Adapt inside the existing App Router product page; keep your own metadata,
2
+ // static params, region lookup, 404 handling, and visible ProductTemplate.
3
+ import { buildProductJsonLd } from "@abhi1693/medusa-nextjs-jsonld"
4
+ import { JsonLd } from "@abhi1693/medusa-nextjs-jsonld/react"
5
+
6
+ // In ProductPage, after loading pricedProduct with the SAME region/customer
7
+ // context used by the visible page and checking it exists:
8
+ export function ProductStructuredData({ product, countryCode, baseUrl }: {
9
+ product: Parameters<typeof buildProductJsonLd>[0]
10
+ countryCode: string
11
+ baseUrl: string
12
+ }) {
13
+ if (!product.handle) return null
14
+ const url = `/${encodeURIComponent(countryCode)}/products/${encodeURIComponent(product.handle)}`
15
+ const data = buildProductJsonLd(product, { baseUrl }, { url })
16
+ return <JsonLd id="product-jsonld" data={data} />
17
+ }
18
+ // Render <ProductStructuredData product={pricedProduct} countryCode={params.countryCode}
19
+ // baseUrl={process.env.NEXT_PUBLIC_BASE_URL!} /> alongside <ProductTemplate ... />.
20
+ // Set baseUrl to your real public store origin, never a request Host header.
package/package.json CHANGED
@@ -1,6 +1,94 @@
1
1
  {
2
2
  "name": "@abhi1693/medusa-nextjs-jsonld",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.3",
4
+ "description": "Safe server-rendered structured data builders for Medusa v2 storefronts",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "examples",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./react": {
20
+ "types": "./dist/react.d.ts",
21
+ "import": "./dist/react.js"
22
+ }
23
+ },
24
+ "sideEffects": false,
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "test": "npm run build && node --test test/*.test.mjs",
31
+ "check": "npm test && npm pack --dry-run",
32
+ "prepack": "npm run build"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.2.0 || ^19.0.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@types/react": "^19.0.0",
44
+ "react": "^19.0.0",
45
+ "react-dom": "^19.0.0",
46
+ "typescript": "^5.8.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/abhi1693/medusa-nextjs-jsonld.git"
51
+ },
52
+ "license": "MIT",
53
+ "typesVersions": {
54
+ "*": {
55
+ "react": [
56
+ "dist/react.d.ts"
57
+ ]
58
+ }
59
+ },
60
+ "homepage": "https://github.com/abhi1693/medusa-nextjs-jsonld#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/abhi1693/medusa-nextjs-jsonld/issues"
63
+ },
64
+ "funding": {
65
+ "type": "github",
66
+ "url": "https://github.com/sponsors/abhi1693"
67
+ },
68
+ "keywords": [
69
+ "medusa",
70
+ "medusa-v2",
71
+ "ecommerce",
72
+ "typescript",
73
+ "nextjs",
74
+ "json-ld",
75
+ "schema-org",
76
+ "structured-data",
77
+ "seo",
78
+ "react",
79
+ "medusa-plugin",
80
+ "medusa-plugin-other"
81
+ ],
82
+ "publishConfig": {
83
+ "access": "public"
84
+ },
85
+ "config": {
86
+ "release": {
87
+ "requiredFiles": [
88
+ "dist/core.js",
89
+ "dist/pagination.js",
90
+ "dist/types.d.ts"
91
+ ]
92
+ }
93
+ }
94
+ }