Open specification · Version 1.0

OpenCraft Beverage Line Format

BLF v1.0

A shared language for beverage lists — readable by people, parsable by machines, and built for every drink.

One line, one productOnly the producer is requiredBuilt for every beverageVersioned · v1.0

Published by OpenCraft · 3 November 2026

BLF v1.0 · Live check

Paste or type one compact BLF record per line. Cards update as you type.

Parsed items
01

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.

02

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.

Pattern
<order>. <producer>: <beverage>, <style> <abv> <size> <container> <currency> @<cost> [V<vat>] [@<cost-incl>v] x<stock> / <price> [<price-incl>v]
Example
2. Sample Brewing: Sea Breeze, Pale Ale 5% 440ml Can THB @180 V7.0 @192.6v x8 /320 342.4v
TokenFieldMeaning
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.

03

Field reference

Every field is optional except Producer Name. The order below is the recommended canonical order for both compact and expanded forms.

FieldRequiredSyntaxNotes
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.

04

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.
05

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.

06

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.

07

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.

TokenPattern
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

  1. Trim the line and skip blanks. One non-empty line is one record.
  2. Consume the leading order number, if present, with the pattern ^\d+\.
  3. 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.
  4. 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.
  5. 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.
  6. Scan the remaining string for parameter tokens using their patterns. Parameter order does not matter.
  7. Normalize container sizes to millilitres for comparison (1 oz = 29.5735 ml), but keep the original unit for display.
  8. 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-parser.js
/**
 * 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.

08

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
09

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.

v1.1

Stable product ID

Order numbers are display indexes. An optional SKU token would let systems match the same product across list updates.

v1.1

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.

v1.1

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.

v1.1

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.

v1.2

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 feedback

BLF v1.0 · Proposed by OpenCraft · 3 November 2026