feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
import BaseCookie from 'js-cookie';
import Singleton from '../abstracts/Singleton';
/**
* Cookie utility.
*
* This utility is a thin wrapper around the "js-cookie" library.
*
* @see https://github.com/js-cookie/js-cookie
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Cookie extends Singleton {
construct() {
this.defaults = {
expires: null,
path: '/',
domain: null,
secure: false,
sameSite: 'Lax',
};
}
/**
* Set the default cookie parameters for all subsequent "set" and "remove" calls.
*
* @param {Object} options
*/
setDefaults(options) {
if (typeof options !== 'object') {
throw new Error('Cookie defaults must be provided as an object');
}
Object.entries(options).forEach((entry) => {
const [key, value] = entry;
if (this.defaults[key] !== undefined) {
this.defaults[key] = value;
}
});
}
/**
* Get the current default cookie parameters.
*
* @returns {Object}
*/
getDefaults() {
const defaults = {};
Object.entries(this.defaults).forEach((entry) => {
const [key, value] = entry;
if (this.defaults[key] !== null) {
defaults[key] = value;
}
});
return defaults;
}
/**
* Get a cookie by name.
*
* If `name` is undefined, returns all cookies as an Object.
*
* @param {String} name
* @returns {Object|String}
*/
get(name) {
if (name === undefined) {
const cookies = BaseCookie.get();
Object.entries(cookies).forEach((entry) => {
const [cookieName, cookieValue] = entry;
this.snowboard.globalEvent('cookie.get', cookieName, cookieValue, (newValue) => {
cookies[cookieName] = newValue;
});
});
return cookies;
}
let value = BaseCookie.get(name);
// Allow plugins to override the gotten value
this.snowboard.globalEvent('cookie.get', name, value, (newValue) => {
value = newValue;
});
return value;
}
/**
* Set a cookie by name.
*
* You can specify additional cookie parameters through the "options" parameter.
*
* @param {String} name
* @param {String} value
* @param {Object} options
* @returns {String}
*/
set(name, value, options) {
let saveValue = value;
// Allow plugins to override the value to save
this.snowboard.globalEvent('cookie.set', name, value, (newValue) => {
saveValue = newValue;
});
return BaseCookie.set(name, saveValue, {
...this.getDefaults(),
...options,
});
}
/**
* Remove a cookie by name.
*
* You can specify the additional cookie parameters via the "options" parameter.
*
* @param {String} name
* @param {Object} options
* @returns {void}
*/
remove(name, options) {
BaseCookie.remove(name, {
...this.getDefaults(),
...options,
});
}
}

View File

@@ -0,0 +1,395 @@
import Singleton from '../abstracts/Singleton';
/**
* JSON Parser utility.
*
* This utility parses JSON-like data that does not strictly meet the JSON specifications in order to simplify development.
* It is a safe replacement for JSON.parse(JSON.stringify(eval("({" + value + "})"))) that does not require the use of eval()
*
* @author Ayumi Hamasaki
* @author Ben Thomson <git@alfreido.com>
* @see https://github.com/octobercms/october/pull/4527
*/
export default class JsonParser extends Singleton {
construct() {
// Add to global function for backwards compatibility
window.wnJSON = (json) => this.parse(json);
window.ocJSON = window.wnJSON;
}
parse(str) {
const jsonString = this.parseString(str);
return JSON.parse(jsonString);
}
parseString(value) {
let str = value.trim();
if (!str.length) {
throw new Error('Broken JSON object.');
}
let result = '';
let type = null;
let key = null;
let body = '';
/*
* the mistake ','
*/
while (str && str[0] === ',') {
str = str.substr(1);
}
/*
* string
*/
if (str[0] === '"' || str[0] === '\'') {
if (str[str.length - 1] !== str[0]) {
throw new Error('Invalid string JSON object.');
}
body = '"';
for (let i = 1; i < str.length; i += 1) {
if (str[i] === '\\') {
if (str[i + 1] === '\'') {
body += str[i + 1];
} else {
body += str[i];
body += str[i + 1];
}
i += 1;
} else if (str[i] === str[0]) {
body += '"';
return body;
} else if (str[i] === '"') {
body += '\\"';
} else {
body += str[i];
}
}
throw new Error('Invalid string JSON object.');
}
/*
* boolean
*/
if (str === 'true' || str === 'false') {
return str;
}
/*
* null
*/
if (str === 'null') {
return 'null';
}
/*
* number
*/
const num = Number(str);
if (!Number.isNaN(num)) {
return num.toString();
}
/*
* object
*/
if (str[0] === '{') {
type = 'needKey';
key = null;
result = '{';
for (let i = 1; i < str.length; i += 1) {
if (this.isBlankChar(str[i])) {
/* eslint-disable-next-line */
continue;
}
if (type === 'needKey' && (str[i] === '"' || str[i] === '\'')) {
key = this.parseKey(str, i + 1, str[i]);
result += `"${key}"`;
i += key.length;
i += 1;
type = 'afterKey';
} else if (type === 'needKey' && this.canBeKeyHead(str[i])) {
key = this.parseKey(str, i);
result += '"';
result += key;
result += '"';
i += key.length - 1;
type = 'afterKey';
} else if (type === 'afterKey' && str[i] === ':') {
result += ':';
type = ':';
} else if (type === ':') {
body = this.getBody(str, i);
i = i + body.originLength - 1;
result += this.parseString(body.body);
type = 'afterBody';
} else if (type === 'afterBody' || type === 'needKey') {
let last = i;
while (str[last] === ',' || this.isBlankChar(str[last])) {
last += 1;
}
if (str[last] === '}' && last === str.length - 1) {
while (result[result.length - 1] === ',') {
result = result.substr(0, result.length - 1);
}
result += '}';
return result;
}
if (last !== i && result !== '{') {
result += ',';
type = 'needKey';
i = last - 1;
}
}
}
throw new Error(`Broken JSON object near ${result}`);
}
/*
* array
*/
if (str[0] === '[') {
result = '[';
type = 'needBody';
for (let i = 1; i < str.length; i += 1) {
if (str[i] === ' ' || str[i] === '\n' || str[i] === '\t') {
/* eslint-disable-next-line */
continue;
} else if (type === 'needBody') {
if (str[i] === ',') {
result += 'null,';
/* eslint-disable-next-line */
continue;
}
if (str[i] === ']' && i === str.length - 1) {
if (result[result.length - 1] === ',') {
result = result.substr(0, result.length - 1);
}
result += ']';
return result;
}
body = this.getBody(str, i);
i = i + body.originLength - 1;
result += this.parseString(body.body);
type = 'afterBody';
} else if (type === 'afterBody') {
if (str[i] === ',') {
result += ',';
type = 'needBody';
// deal with mistake ","
while (str[i + 1] === ',' || this.isBlankChar(str[i + 1])) {
if (str[i + 1] === ',') {
result += 'null,';
}
i += 1;
}
} else if (str[i] === ']' && i === str.length - 1) {
result += ']';
return result;
}
}
}
throw new Error(`Broken JSON array near ${result}`);
}
return '';
}
getBody(str, pos) {
let body = '';
// parse string body
if (str[pos] === '"' || str[pos] === '\'') {
body = str[pos];
for (let i = pos + 1; i < str.length; i += 1) {
if (str[i] === '\\') {
body += str[i];
if (i + 1 < str.length) {
body += str[i + 1];
}
i += 1;
} else if (str[i] === str[pos]) {
body += str[pos];
return {
originLength: body.length,
body,
};
} else {
body += str[i];
}
}
throw new Error(`Broken JSON string body near ${body}`);
}
// parse true / false
if (str[pos] === 't') {
if (str.indexOf('true', pos) === pos) {
return {
originLength: 'true'.length,
body: 'true',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
if (str[pos] === 'f') {
if (str.indexOf('f', pos) === pos) {
return {
originLength: 'false'.length,
body: 'false',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
// parse null
if (str[pos] === 'n') {
if (str.indexOf('null', pos) === pos) {
return {
originLength: 'null'.length,
body: 'null',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
// parse number
if (str[pos] === '-' || str[pos] === '+' || str[pos] === '.' || (str[pos] >= '0' && str[pos] <= '9')) {
body = '';
for (let i = pos; i < str.length; i += 1) {
if (str[i] === '-' || str[i] === '+' || str[i] === '.' || (str[i] >= '0' && str[i] <= '9')) {
body += str[i];
} else {
return {
originLength: body.length,
body,
};
}
}
throw new Error(`Broken JSON number body near ${body}`);
}
// parse object
if (str[pos] === '{' || str[pos] === '[') {
const stack = [
str[pos],
];
body = str[pos];
for (let i = pos + 1; i < str.length; i += 1) {
body += str[i];
if (str[i] === '\\') {
if (i + 1 < str.length) {
body += str[i + 1];
}
i += 1;
} else if (str[i] === '"') {
if (stack[stack.length - 1] === '"') {
stack.pop();
} else if (stack[stack.length - 1] !== '\'') {
stack.push(str[i]);
}
} else if (str[i] === '\'') {
if (stack[stack.length - 1] === '\'') {
stack.pop();
} else if (stack[stack.length - 1] !== '"') {
stack.push(str[i]);
}
} else if (stack[stack.length - 1] !== '"' && stack[stack.length - 1] !== '\'') {
if (str[i] === '{') {
stack.push('{');
} else if (str[i] === '}') {
if (stack[stack.length - 1] === '{') {
stack.pop();
} else {
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
} else if (str[i] === '[') {
stack.push('[');
} else if (str[i] === ']') {
if (stack[stack.length - 1] === '[') {
stack.pop();
} else {
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
}
}
if (!stack.length) {
return {
originLength: i - pos,
body,
};
}
}
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
throw new Error(`Broken JSON body near ${str.substr((pos - 5 >= 0) ? pos - 5 : 0, 50)}`);
}
parseKey(str, pos, quote) {
let key = '';
for (let i = pos; i < str.length; i += 1) {
if (quote && quote === str[i]) {
return key;
}
if (!quote && (str[i] === ' ' || str[i] === ':')) {
return key;
}
key += str[i];
if (str[i] === '\\' && i + 1 < str.length) {
key += str[i + 1];
i += 1;
}
}
throw new Error(`Broken JSON syntax near ${key}`);
}
canBeKeyHead(ch) {
if (ch[0] === '\\') {
return false;
}
if ((ch[0] >= 'a' && ch[0] <= 'z') || (ch[0] >= 'A' && ch[0] <= 'Z') || ch[0] === '_') {
return true;
}
if (ch[0] >= '0' && ch[0] <= '9') {
return true;
}
if (ch[0] === '$') {
return true;
}
if (ch.charCodeAt(0) > 255) {
return true;
}
return false;
}
isBlankChar(ch) {
return ch === ' ' || ch === '\n' || ch === '\t';
}
}

View File

@@ -0,0 +1,64 @@
import Singleton from '../abstracts/Singleton';
/**
* Sanitizer utility.
*
* Client-side HTML sanitizer designed mostly to prevent self-XSS attacks.
* The sanitizer utility will strip all attributes that start with `on` (usually JS event handlers as attributes, i.e. `onload` or `onerror`) or contain the `javascript:` pseudo protocol in their values.
*
* @author Ben Thomson <git@alfreido.com>
*/
export default class Sanitizer extends Singleton {
construct() {
// Add to global function for backwards compatibility
window.wnSanitize = (html) => this.sanitize(html);
window.ocSanitize = window.wnSanitize;
}
sanitize(html, bodyOnly) {
const parser = new DOMParser();
const dom = parser.parseFromString(html, 'text/html');
const returnBodyOnly = (bodyOnly !== undefined && typeof bodyOnly === 'boolean')
? bodyOnly
: true;
this.sanitizeNode(dom.getRootNode());
return (returnBodyOnly) ? dom.body.innerHTML : dom.innerHTML;
}
sanitizeNode(node) {
if (node.tagName === 'SCRIPT') {
node.remove();
return;
}
this.trimAttributes(node);
const children = Array.from(node.children);
children.forEach((child) => {
this.sanitizeNode(child);
});
}
trimAttributes(node) {
if (!node.attributes) {
return;
}
for (let i = 0; i < node.attributes.length; i += 1) {
const attrName = node.attributes.item(i).name;
const attrValue = node.attributes.item(i).value;
/*
* remove attributes where the names start with "on" (for example: onload, onerror...)
* remove attributes where the value starts with the "javascript:" pseudo protocol (for example href="javascript:alert(1)")
*/
/* eslint-disable-next-line */
if (attrName.indexOf('on') === 0 || attrValue.indexOf('javascript:') === 0) {
node.removeAttribute(attrName);
}
}
}
}

View File

@@ -0,0 +1,165 @@
import Singleton from '../abstracts/Singleton';
/**
* URL utility.
*
* This utility provides URL functions.
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Url extends Singleton {
construct() {
this.foundBaseUrl = null;
this.foundAssetUrl = null;
this.baseUrl();
this.assetUrl();
}
/**
* Gets a URL based on a relative path.
*
* If an absolute URL is provided, it will be returned unchanged.
*
* @param {string} url
* @returns {string}
*/
to(url) {
const urlRegex = /^(?:[^:]+:\/\/)[-a-z0-9@:%._+~#=]{1,256}\b([-a-z0-9()@:%_+.~#?&//=]*)/i;
if (url.match(urlRegex)) {
return url;
}
const theUrl = url.replace(/^\/+/, '');
return `${this.baseUrl()}${theUrl}`;
}
/**
* Gets an Asset URL based on a relative path.
*
* If an absolute URL is provided, it will be returned unchanged.
*
* @param {string} url
* @returns {string}
*/
asset(url) {
const urlRegex = /^(?:[^:]+:\/\/)[-a-z0-9@:%._+~#=]{1,256}\b([-a-z0-9()@:%_+.~#?&//=]*)/i;
if (url.match(urlRegex)) {
return url;
}
const theUrl = url.replace(/^\/+/, '');
return `${this.assetUrl()}${theUrl}`;
}
/**
* Helper method to get the base URL of this install.
*
* This determines the base URL from three sources, in order:
* - If Snowboard is loaded via the `{% snowboard %}` tag, it will retrieve the base URL that
* is automatically included there.
* - If a `<base>` tag is available, it will use the URL specified in the base tag.
* - Finally, it will take a guess from the current location. This will likely not work for sites
* that reside in subdirectories.
*
* The base URL will always contain a trailing backslash.
*
* @returns {string}
*/
baseUrl() {
if (this.foundBaseUrl !== null) {
return this.foundBaseUrl;
}
if (document.querySelector('script[data-module="snowboard-base"]') !== null) {
this.foundBaseUrl = this.validateBaseUrl(document.querySelector('script[data-module="snowboard-base"]').dataset.baseUrl);
return this.foundBaseUrl;
}
if (document.querySelector('base') !== null) {
this.foundBaseUrl = this.validateBaseUrl(document.querySelector('base').getAttribute('href'));
return this.foundBaseUrl;
}
const urlParts = [
window.location.protocol,
'//',
window.location.host,
'/',
];
this.foundBaseUrl = urlParts.join('');
return this.foundBaseUrl;
}
/**
* Helper method to get the asset URL of this install.
*
* This determines the base URL from three sources, in order:
* - If Snowboard is loaded via the `{% snowboard %}` tag, it will retrieve the asset URL that
* is automatically included there.
* - If a `<link rel="asset_url" href="https://example.com">` tag is available, it will use the URL specified in the link tag.
* - Finally, it will take a guess from the current location. This will likely not work for sites
* that reside in subdirectories.
*
* The asset URL will always contain a trailing backslash.
*
* @returns {string}
*/
assetUrl() {
if (this.foundAssetUrl !== null) {
return this.foundAssetUrl;
}
if (document.querySelector('script[data-module="snowboard-base"]') !== null) {
this.foundAssetUrl = this.validateBaseUrl(document.querySelector('script[data-module="snowboard-base"]').dataset.assetUrl);
return this.foundAssetUrl;
}
if (document.querySelector('link[rel="asset_url"]') !== null) {
this.foundAssetUrl = this.validateBaseUrl(document.querySelector('link[rel="asset_url"]').getAttribute('href'));
return this.foundAssetUrl;
}
const urlParts = [
window.location.protocol,
'//',
window.location.host,
'/',
];
this.foundAssetUrl = urlParts.join('');
return this.foundAssetUrl;
}
/**
* Validates the base URL, ensuring it is a HTTP/HTTPs URL.
*
* If the Snowboard script or <base> tag on the page use a different type of URL, this will fail with
* an error.
*
* @param {string} url
* @returns {string}
*/
validateBaseUrl(url) {
const urlRegex = /^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/i;
const urlParts = urlRegex.exec(url);
const protocol = urlParts[2];
const domain = urlParts[4];
if (protocol && ['http', 'https'].indexOf(protocol.toLowerCase()) === -1) {
throw new Error('Invalid base URL detected');
}
if (!domain) {
throw new Error('Invalid base URL detected');
}
return (url.substr(-1) === '/')
? url
: `${url}/`;
}
}