OpenCraft Beverage Line Format
A shared language for beverage lists — readable by people, parsable by machines, and built for every drink.
Published by OpenCraft · 3 November 2026
Paste or type one compact BLF record per line. Cards update as you type.
Why BLF
Beverage lists travel between producers, distributors, bars, and customers every day — through chat messages, printed menus, spreadsheets, and order forms. Every sender invents a slightly different layout, so every receiver re-types the data.
BLF (Beverage Line Format) is a small, line-oriented standard proposed by OpenCraft. One line describes one beverage: who makes it, what it is, how strong it is, how it is packaged, what it costs, how many are left, and what it sells for. A human can read it at a glance; a machine can parse it with a handful of deterministic rules.
Human first
A line must be understandable to a bartender or buyer without any tooling. No XML, no nested objects — just text.
Machine by rule
Every token has an unambiguous prefix, suffix, or unit. Parsers never need to guess where a field ends.
Optional by default
Only Producer Name is mandatory. A list can start minimal and grow richer over time without breaking.
Extensible vocabulary
Container types and styles are controlled lists that can grow. BLF is a beverage format, not a beer-only format.
Anatomy of one line
Read left to right. The order number and the producer form the head of the line. Everything after it is a parameter, identified by its own sigil, unit, or currency code rather than by position.
<order>. <producer>: <beverage>, <style> <abv> <size> <container> <currency> @<cost> [V<vat>] [@<cost-incl>v] x<stock> / <price> [<price-incl>v] 2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 /320 342.4v | Token | Field | Meaning |
|---|---|---|
2. | Order Number | Index within the list. Not a product ID. |
Sample Brewing | Producer Name | The only required field. Ends at the colon. |
Sea Breeze | Beverage Name | After the colon, before the last comma. |
Pale Ale | Style | After the last comma in the head. |
5% | ABV | Alcohol by volume. Decimals are allowed. |
440ml | Container Size | Units: ml, cl, L, or oz. |
Can | Container Type | Can, Bottle, Keg, Cask, Growler, and future additions. |
THB | Currency | ISO 4217 code. Applies to every amount in the record. |
@180 | Cost (VAT excluded) | Per container, in the stated or agreed currency. |
V7.0 | VAT rate | Percent. Applies to the whole record. |
@192.6v | Cost (VAT included) | The v suffix marks a VAT-inclusive amount. |
x8 | Stock Amount | Integer count of containers. |
/320 | Retail Price (VAT excluded) | Per container. |
342.4v | Retail Price (VAT included) | The v suffix marks a VAT-inclusive amount. |
Parameters are order-independent: 5% 440ml Can and 440ml 5% Can describe the same product. The order shown above is the recommended canonical order. The order number is optional too — when it is omitted, the line starts with the producer.
Field reference
Every field is optional except Producer Name. The order below is the recommended canonical order for both compact and expanded forms.
| Field | Required | Syntax | Notes |
|---|---|---|---|
| Order Number | Optional | 1. | Line index. Unique within a published list. Display order only — not a stable product identifier. |
| Producer Name | Required | Sample Brewing: | Brand, brewery, or producer. May contain commas; the field ends at the colon. |
| Beverage Name | Optional | Sea Breeze, | Product name. Split from Style at the last comma in the head. |
| Style | Optional | Pale Ale | Free text in v1.0. A shared style registry is planned. |
| ABV | Optional | 5% | Alcohol by volume as a percentage. Decimals are allowed; 0% is valid. |
| Container Size | Optional | 440ml | Number plus unit: ml, cl, L, or oz. oz is a US fluid ounce (29.5735 ml). Normalize to millilitres when comparing. |
| Container Type | Optional | Can | Can, Bottle, Keg, Cask, Growler, and future additions. Extensible. |
| Currency | Optional | THB | ISO 4217 code. Applies to every amount in the record. When omitted, the list’s agreed currency applies. |
| Cost (VAT excluded) | Optional | @180 | Cost per container, in the stated or agreed currency. Cost is shorthand for this field. |
| VAT rate | Optional | V7.0 | Percent, applied to every amount in the record. In Thailand, 7.0. |
| Cost (VAT included) | Optional | @192.6v | Stated or derived. The v suffix is mandatory. |
| Stock Amount | Optional | x8 | Integer count of containers — cans, bottles, or kegs, not servings. |
| Retail Price (VAT excluded) | Optional | /320 | Price per container. Retail Price is shorthand for this field. |
| Retail Price (VAT included) | Optional | 342.4v | Stated or derived. The v suffix is mandatory. |
An amount written without a v suffix is VAT-excluded. When Currency is omitted, amounts use the list’s agreed currency; a single record should not mix currencies.
Two equivalent forms
The compact form is built for chat messages and shelf labels. The expanded form is built for review, editing, and version control. Both carry the same data and convert to each other without loss.
Compact form — one line per product
The order number and producer lead the line; parameters follow, each identified by a sigil, unit, or ISO currency code. Position carries no meaning, so the canonical order is a readability convention rather than a parsing requirement.
1. Mock Brewery: Night Owl, Chocolate Milk Stout 5.6% 12oz Bottle THB @120 x5 / 260
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 / 320 342.4v
3. Demo Lagerhaus, Munich Helles 5.5% - Parameters are detected by @, V, x, / and units such as %, ml, and L.
- Amounts use a dot decimal. Never use thousands separators inside an amount.
- One record per line. Blank lines between records are allowed.
- Keywords and units are case-insensitive; the capitalization shown here is canonical.
- Currency is a standalone ISO 4217 code such as THB, placed before the first amount.
- A space after @ or / is allowed, so @180 and @ 180 are equivalent.
- Publish one line per sellable container variant — 330ml and 500ml of the same beer are two lines.
- If a name contains a token-like fragment, or the style contains a comma, use the expanded form instead.
Expanded form — one field per line
Write Key: Value, and separate records with a blank line. This form is easier to review, diff, and enter by hand. It is the safest choice when names contain commas, numbers, or units.
Order Number: 2
Producer Name: Sample Brewing
Beverage Name: Sea Breeze
Style: Pale Ale
ABV: 5%
Container Size: 440ml
Container Type: Can
Currency: THB
Cost (VAT Excluded): 180
VAT: 7.0%
Cost (VAT Included): 192.6
Stock Amount: 8
Retail Price (VAT Excluded): 320
Retail Price (VAT Included): 342.4 - Field names are case-insensitive; use the canonical spelling when publishing.
- Cost and Retail Price are accepted aliases for the VAT-excluded fields.
- Readers should ignore unknown fields rather than reject the record.
- A blank line terminates a record; omitted fields are simply absent.
VAT rules
Money is VAT-excluded by default because that is how most trade lists are quoted. The v suffix marks an amount that already includes VAT. Both can coexist in one record.
The default is VAT-excluded
Any amount without a v suffix is treated as VAT-excluded. This applies to both cost and retail price.
v marks a VAT-inclusive amount
Attach v directly to the amount: @192.6v or /342.4v. A bare amount ending in v immediately after the matching exclusive amount is also accepted, but that form is positional — for new lists, prefer the sigil-attached form.
V<rate> declares the rate
The rate applies to every amount in the record. In Thailand the standard rate is V7.0.
Deriving the inclusive amount
With a known exclusive amount and rate, compute amount × (1 + rate / 100), rounded half-up to two decimal places.
Consistency is checked
If a record states both exclusive and inclusive amounts, they should differ by no more than 0.01. A larger gap is a data error.
A rate is not always required
An amount may be marked v without a stated rate. The amount is then known to include VAT, but the rate is unknown — do not infer it.
Worked example: record 2
Currency: THB
Cost (VAT Excluded): 180
VAT: 7.0%
Cost (VAT Included): 192.6
Retail Price (VAT Excluded): 320
Retail Price (VAT Included): 342.4 180 × 1.07 = 192.60 and 320 × 1.07 = 342.40, both exact to two decimal places.
Six mock examples
The six records below are fictional mock data, included to exercise every part of the format. Each card shows the compact line and its expanded fields.
Mock Brewery — Night Owl
1. Mock Brewery: Night Owl, Chocolate Milk Stout 5.6% 12oz Bottle THB @120 x5 / 260 Expanded fields
- Order Number
- 1
- Producer Name
- Mock Brewery
- Beverage Name
- Night Owl
- Style
- Chocolate Milk Stout
- ABV
- 5.6%
- Container Size
- 12oz
- Container Type
- Bottle
- Currency
- THB
- Cost (VAT Excluded)
- 120
- Stock Amount
- 5
- Retail Price (VAT Excluded)
- 260
No VAT is stated, so both monetary amounts are VAT-excluded by default. The 12oz bottle demonstrates the oz size unit.
Sample Brewing — Sea Breeze
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 / 320 342.4v Expanded fields
- Order Number
- 2
- Producer Name
- Sample Brewing
- Beverage Name
- Sea Breeze
- Style
- Pale Ale
- ABV
- 5%
- Container Size
- 440ml
- Container Type
- Can
- Currency
- THB
- Cost (VAT Excluded)
- 180
- VAT
- 7.0%
- Cost (VAT Included)
- 192.6
- Stock Amount
- 8
- Retail Price (VAT Excluded)
- 320
- Retail Price (VAT Included)
- 342.4
The fullest mock record: rate, stock, and both VAT variants of cost and price are present.
Sample Brewing — Sunbeam
3. Sample Brewing: Sunbeam, Fruity Pale Ale 4.8% 440ml Can THB @150v x10 / 280v Expanded fields
- Order Number
- 3
- Producer Name
- Sample Brewing
- Beverage Name
- Sunbeam
- Style
- Fruity Pale Ale
- ABV
- 4.8%
- Container Size
- 440ml
- Container Type
- Can
- Currency
- THB
- Cost (VAT Included)
- 150
- Stock Amount
- 10
- Retail Price (VAT Included)
- 280
VAT-inclusive amounts without a stated rate are valid. The rate stays unknown rather than being guessed.
Demo Lagerhaus — Munich Helles
4. Demo Lagerhaus, Munich Helles 5.5% Expanded fields
- Order Number
- 4
- Producer Name
- Demo Lagerhaus
- Style
- Munich Helles
- ABV
- 5.5%
No colon: the comma separates the producer from the style, and there is no beverage name.
Mock Cocktail Co. — Citrus Club
5. Mock Cocktail Co.: Citrus Club, Pre-made Cocktail 330ml 5% Expanded fields
- Order Number
- 5
- Producer Name
- Mock Cocktail Co.
- Beverage Name
- Citrus Club
- Style
- Pre-made Cocktail
- Container Size
- 330ml
- ABV
- 5%
Size appears before ABV — parameters are order-independent. This is a non-beer beverage in the same format.
Demo Brewing — Love, Laugh, and Sorrow
6. Demo Brewing: Love, Laugh, and Sorrow, QDH Hazy IPA 7.5% 20L Keg Expanded fields
- Order Number
- 6
- Producer Name
- Demo Brewing
- Beverage Name
- Love, Laugh, and Sorrow
- Style
- QDH Hazy IPA
- ABV
- 7.5%
- Container Size
- 20L
- Container Type
- Keg
The last comma separates the beverage name from the style, so commas inside the beverage name are safe.
For developers
BLF is designed to be tokenized with a handful of regular expressions and a short set of rules. No dedicated library is required.
| Token | Pattern |
|---|---|
| Order Number | ^\d+\. |
| ABV | \d+(?:\.\d+)?% |
| Container Size | \d+(?:\.\d+)?(?:ml|cl|L|oz)\b |
| Container Type | \b(?:Bottle|Can|Keg|Cask|Growler)\b |
| Currency (ISO 4217) | \b[A-Z]{3}\b |
| Cost (VAT excluded) | @\d+(?:\.\d+)? |
| VAT rate | V\d+(?:\.\d+)? |
| Cost (VAT included) | @?\d+(?:\.\d+)?v |
| Stock Amount | x\d+ |
| Retail Price (VAT excluded) | /\d+(?:\.\d+)? |
| Retail Price (VAT included) | /?\d+(?:\.\d+)?v |
Parsing the compact form
- Trim the line and skip blanks. One non-empty line is one record.
- Consume the leading order number, if present, with the pattern ^\d+\.
- If a standalone ISO 4217 code (for example THB) appears in the parameter section, record it as the currency for every amount in the record.
- Split the head at the first colon. If there is no colon, split at the first comma: before it is the producer, after it is the style.
- Inside the head after the colon, split at the last comma: before it is the beverage name, after it is the style. With no comma, the whole head is the beverage name.
- Scan the remaining string for parameter tokens using their patterns. Parameter order does not matter.
- Normalize container sizes to millilitres for comparison (1 oz = 29.5735 ml), but keep the original unit for display.
- Apply the VAT rules and reject records whose stated exclusive and inclusive amounts disagree by more than 0.01.
Reference parser (JavaScript)
The exact parser that powers the live checker above. It has no dependencies and is safe to copy into a project as a starting point.
/**
* BLF v1.0 reference parser — compact form.
*
* Usage:
* import { parseBlf } from './blf-parser.js';
* const { results, itemCount, warningCount, errorCount } = parseBlf(text);
*
* Each non-empty line is one record. The order number is optional; the
* Producer Name is the only required field. Parameters are recognised by
* their sigil or unit, so they may appear in any order. An optional ISO 4217
* currency code (for example THB) applies to every amount in the record.
*/
const CONTAINER_TYPES = ['bottle', 'can', 'keg', 'cask', 'growler'];
// Use the runtime's ISO 4217 list when available; otherwise accept any
// uppercase three-letter token and let the caller validate it.
const CURRENCY_CODES =
typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function'
? new Set(Intl.supportedValuesOf('currency'))
: null;
const isCurrencyCode = (token) =>
/^[A-Z]{3}$/.test(token) && (CURRENCY_CODES === null || CURRENCY_CODES.has(token));
const round2 = (value) => Math.round((value + Number.EPSILON) * 100) / 100;
const normalizeContainer = (token) => {
const match = CONTAINER_TYPES.find((type) => type === token.toLowerCase());
return match ? match.charAt(0).toUpperCase() + match.slice(1) : null;
};
/**
* Classify a whitespace-separated parameter token, or return null when the
* token is not a parameter.
*/
const classifyToken = (token) => {
if (/^@\d+(?:\.\d+)?v?$/i.test(token)) return 'cost';
if (/^\/\d+(?:\.\d+)?v?$/i.test(token)) return 'retail';
if (/^V\d+(?:\.\d+)?%?$/i.test(token)) return 'vat';
if (/^x\d+$/i.test(token)) return 'stock';
if (/^\d+(?:\.\d+)?%$/.test(token)) return 'abv';
if (/^\d+(?:\.\d+)?(?:ml|cl|L|oz)$/i.test(token)) return 'size';
if (normalizeContainer(token)) return 'container';
if (isCurrencyCode(token)) return 'currency';
if (/^\d+(?:\.\d+)?v$/i.test(token)) return 'inc';
return null;
};
/**
* Split the text after the order number into a name head and parameter
* tokens. A token only starts the parameter section if it is a strong
* parameter (not a bare container word), so names containing "Can" or
* "Cask" survive better.
*/
const splitHeadAndParams = (text) => {
const tokens = text.split(/\s+/).filter(Boolean);
let firstParam = tokens.length;
for (let i = 0; i < tokens.length; i += 1) {
const kind = classifyToken(tokens[i]);
if (kind && kind !== 'container') {
firstParam = i;
break;
}
}
return {
head: tokens.slice(0, firstParam).join(' '),
params: tokens.slice(firstParam),
};
};
/**
* Parse the head into producer, beverage, and style.
* With a colon: producer : beverage , style
* Without colon: producer , style
*/
const parseHead = (head) => {
const colon = head.indexOf(':');
if (colon !== -1) {
const producer = head.slice(0, colon).trim();
const tail = head.slice(colon + 1).trim();
const lastComma = tail.lastIndexOf(',');
return {
producer,
beverage: lastComma === -1 ? tail || null : tail.slice(0, lastComma).trim() || null,
style: lastComma === -1 ? null : tail.slice(lastComma + 1).trim() || null,
};
}
const firstComma = head.indexOf(',');
if (firstComma !== -1) {
return {
producer: head.slice(0, firstComma).trim(),
beverage: null,
style: head.slice(firstComma + 1).trim() || null,
};
}
return { producer: head.trim(), beverage: null, style: null };
};
/**
* Parse one compact BLF line.
* Returns { lineNumber, raw, ok, item, warnings, error }.
*/
export const parseBlfLine = (raw, lineNumber = 1) => {
const line = String(raw ?? '').trim();
const result = {
lineNumber,
raw: line,
ok: false,
item: null,
warnings: [],
error: null,
};
if (!line) return result;
// 1. Optional order number.
let rest = line;
let orderNumber = null;
const orderMatch = rest.match(/^(\d+)\s*\.\s*/);
if (orderMatch) {
orderNumber = Number(orderMatch[1]);
rest = rest.slice(orderMatch[0].length).trim();
}
// The examples write prices as "/ 320" with a space. Treat the space after
// a price or cost sigil as optional so both "/320" and "/ 320" work.
rest = rest.replace(/\/\s+(?=\d)/g, '/').replace(/@\s+(?=\d)/g, '@');
// 2. Locate the first parameter token.
const { head, params } = splitHeadAndParams(rest);
const parsedHead = parseHead(head);
if (!parsedHead.producer) {
result.error = { code: 'missing-producer' };
return result;
}
const item = {
orderNumber,
producerName: parsedHead.producer,
beverageName: parsedHead.beverage,
style: parsedHead.style,
abv: null,
containerSize: null,
containerType: null,
currency: null,
costExVat: null,
vatRate: null,
costIncVat: null,
stockAmount: null,
retailPriceExVat: null,
retailPriceIncVat: null,
};
// 3. Parse parameter tokens in order.
let previousKind = null;
for (const token of params) {
const kind = classifyToken(token);
if (kind === 'cost') {
const match = token.match(/^@(\d+(?:\.\d+)?)(v)?$/i);
if (match) {
if (match[2]) item.costIncVat = Number(match[1]);
else item.costExVat = Number(match[1]);
}
} else if (kind === 'retail') {
const match = token.match(/^\/(\d+(?:\.\d+)?)(v)?$/i);
if (match) {
if (match[2]) item.retailPriceIncVat = Number(match[1]);
else item.retailPriceExVat = Number(match[1]);
}
} else if (kind === 'vat') {
item.vatRate = Number(token.slice(1).replace('%', ''));
} else if (kind === 'stock') {
item.stockAmount = Number(token.slice(1));
} else if (kind === 'abv') {
item.abv = Number(token.slice(0, -1));
} else if (kind === 'size') {
// oz is a US fluid ounce (29.5735 ml); the original unit is preserved.
const match = token.match(/^(\d+(?:\.\d+)?)(ml|cl|L|oz)$/i);
if (match) {
item.containerSize = {
value: Number(match[1]),
unit: match[2].toLowerCase() === 'l' ? 'L' : match[2].toLowerCase(),
};
}
} else if (kind === 'container') {
item.containerType = normalizeContainer(token);
} else if (kind === 'currency') {
item.currency = token.toUpperCase();
} else if (kind === 'inc') {
const value = Number(token.slice(0, -1));
if (previousKind === 'retail') item.retailPriceIncVat = value;
else if (previousKind === 'cost' || previousKind === 'vat') item.costIncVat = value;
else if (item.retailPriceExVat !== null && item.retailPriceIncVat === null) {
item.retailPriceIncVat = value;
} else if (item.costExVat !== null && item.costIncVat === null) {
item.costIncVat = value;
} else {
result.warnings.push({ code: 'unknown-token', token });
}
} else {
result.warnings.push({ code: 'unknown-token', token });
}
if (kind) previousKind = kind;
}
// 4. Validate stated VAT pairs when a rate is present.
if (item.vatRate !== null) {
const factor = 1 + item.vatRate / 100;
const checks = [
['cost', item.costExVat, item.costIncVat],
['retail', item.retailPriceExVat, item.retailPriceIncVat],
];
for (const [field, exclusive, inclusive] of checks) {
if (exclusive === null || inclusive === null) continue;
const expected = round2(exclusive * factor);
if (Math.abs(expected - inclusive) > 0.01) {
result.warnings.push({
code: 'vat-mismatch',
field,
expected,
actual: inclusive,
});
}
}
}
result.item = item;
result.ok = true;
return result;
};
/**
* Parse a multi-line BLF document.
* Blank lines are skipped; every other line becomes a result in input order.
*/
export const parseBlf = (text) => {
const results = [];
const lines = String(text ?? '').split(/\r?\n/);
lines.forEach((line, index) => {
if (line.trim() === '') return;
results.push(parseBlfLine(line, index + 1));
});
return {
results,
itemCount: results.filter((entry) => entry.ok).length,
warningCount: results.reduce((total, entry) => total + entry.warnings.length, 0),
errorCount: results.filter((entry) => !entry.ok).length,
};
};
Canonical JSON mapping
{
"orderNumber": 2,
"producerName": "Sample Brewing",
"beverageName": "Sea Breeze",
"style": "Pale Ale",
"abv": 5,
"containerSize": { "value": 440, "unit": "ml" },
"containerType": "Can",
"currency": "THB",
"costExVat": 180,
"vatRate": 7.0,
"costIncVat": 192.6,
"stockAmount": 8,
"retailPriceExVat": 320,
"retailPriceIncVat": 342.4
} Omit absent fields rather than emitting null. Amounts are JSON numbers, the currency is an ISO 4217 code, and the original container unit is preserved. When currency is omitted, the list’s agreed currency applies.
Beyond beer
BLF stands for Beverage Line Format, and every field works for any packaged drink. Beer is simply one category among many.
Pre-made cocktails
The same fields describe a canned cocktail. Style carries the product category, and ABV still applies.
5. Mock Cocktail Co.: Citrus Club, Pre-made Cocktail 330ml 5% Mead, wine & sake
Producer, beverage, style, ABV, container, and price contain nothing beer-specific. Use the appropriate style vocabulary.
7. Mock Meadery: Wildflower, Semi-Sweet Mead 12% 375ml Bottle THB @320 x12 / 650 Zero-alcohol
Zero is a valid ABV. Use a style such as Non-Alcoholic, Hop Water, or Soft Drink.
8. Mock Brewery: Hop Water, Non-Alcoholic Sparkling 0% 330ml Can THB @28 x24 / 65 Limitations and the road to v1.1
A good standard states its own limits. These are the gaps in v1.0 that OpenCraft is considering for the next versions.
Stable product ID
Order numbers are display indexes. An optional SKU token would let systems match the same product across list updates.
List header and metadata
A list-level header could declare the BLF version, default currency, default VAT rate, and effective date once, instead of repeating them in every record.
Style registry
Style is free text in v1.0. A shared registry of canonical names would make search and filtering reliable while keeping free text allowed.
Escaping and quoting
Names containing token-like fragments, such as a beverage called “440ml”, need an escape hatch. The expanded form is the current workaround.
Reference test suite
A public test corpus would ensure that two independent parsers agree on every edge case.
Try it, then tell us what broke
BLF is an open proposal, not a finished industry standard. Implement it, test it with your real lists, and send corrections or edge cases. Real-world examples are the fastest way to make v1.1 better.
📧 Send feedbackBLF v1.0 · Proposed by OpenCraft · 3 November 2026